From 9ec2f55c13c05cd027ab8e89f703fa26247634c9 Mon Sep 17 00:00:00 2001 From: Tamas Besenyei Date: Wed, 12 Jun 2019 11:09:43 +0200 Subject: [PATCH 01/45] bundler --- .Jenkinsfile | 34 + .eslintrc.json | 71 + .gitignore | 2 + build/css-resolve.js | 91 + build/external-alias.js | 31 + build/path.js | 34 + build/postcss.config.js | 29 + build/rollup.config.js | 85 + build/scss.js | 102 + build/testrunner.js | 79 + build/webserver.js | 54 + environment/config.js | 79 + environment/qunit2-parameterize.js | 172 ++ environment/require.js | 2145 ++++++++++++++++ package-lock.json | 3669 ++++++++++++++++++++++++++++ package.json | 70 + 16 files changed, 6747 insertions(+) create mode 100644 .Jenkinsfile create mode 100644 .eslintrc.json create mode 100644 build/css-resolve.js create mode 100644 build/external-alias.js create mode 100644 build/path.js create mode 100644 build/postcss.config.js create mode 100644 build/rollup.config.js create mode 100644 build/scss.js create mode 100644 build/testrunner.js create mode 100644 build/webserver.js create mode 100644 environment/config.js create mode 100644 environment/qunit2-parameterize.js create mode 100644 environment/require.js create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.Jenkinsfile b/.Jenkinsfile new file mode 100644 index 00000000..da96dfe5 --- /dev/null +++ b/.Jenkinsfile @@ -0,0 +1,34 @@ +pipeline { + agent { + label 'master' + } + stages { + stage('Frontend Tests') { + agent { + docker { + image 'btamas/puppeteer-git' + reuseNode true + } + } + environment { + HOME = '.' + PARALLEL_TESTS = 2 + } + options { + skipDefaultCheckout() + } + steps { + dir('.') { + sh( + label: 'Setup frontend toolchain', + script: 'npm install' + ) + sh ( + label : 'Run frontend tests', + script: 'npm run test' + ) + } + } + } + } +} \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 00000000..5905ed96 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,71 @@ +{ + "env" : { + "browser" : true, + "es6" : true, + "qunit" : true, + "node" : true + }, + "globals" : { + "ENVIRONMENT" : true + }, + "plugins" : ["es", "jsdoc"], + "parserOptions" : { + "sourceType" : "module", + "ecmaVersion" : 2015 + }, + "extends" : "eslint:recommended", + "rules" : { + "array-bracket-newline" : ["warn", "consistent"], + "arrow-body-style" : ["error", "as-needed"], + "arrow-spacing" : ["warn", { "before" : true, "after" : true }], + "brace-style" : ["warn", "1tbs"], + "consistent-this" : ["error", "self"], + "eqeqeq" : ["error", "smart"], + "es/no-classes" : ["error"], + "es/no-generators" : ["error"], + "func-call-spacing" : ["error"], + "implicit-arrow-linebreak" : ["error"], + "indent" : ["warn", 4, { "SwitchCase" : 1, "MemberExpression" : "off" }], + "jsdoc/check-alignment" : ["warn"], + "jsdoc/check-param-names" : ["warn"], + "jsdoc/require-param" : ["warn"], + "jsdoc/require-param-name" : ["warn"], + "jsdoc/require-param-type" : ["warn"], + "jsdoc/require-returns" : ["warn"], + "jsdoc/require-returns-check" : ["warn"], + "jsdoc/require-returns-type" : ["warn"], + "linebreak-style" : ["error", "unix"], + "new-parens" : ["error"], + "no-alert" : ["error"], + "no-caller" : ["error"], + "no-confusing-arrow" : ["error", { "allowParens" : false }], + "no-console" : ["error"], + "no-debugger" : ["error"], + "no-duplicate-imports" : ["error"], + "no-eval" : ["error"], + "no-extend-native" : ["error"], + "no-extra-bind" : ["error"], + "no-implicit-globals" : ["error"], + "no-implied-eval" : ["error"], + "no-lone-blocks" : ["error"], + "no-multi-assign" : ["error"], + "no-new-func" : ["error"], + "no-script-url" : ["error"], + "no-self-compare" : ["error"], + "no-sequences" : ["error"], + "no-shadow" : ["error", { "hoist" : "functions" }], + "no-template-curly-in-string" : ["error"], + "no-throw-literal" : ["error"], + "no-trailing-spaces" : ["error"], + "no-undefined" : ["error"], + "no-use-before-define" : ["error", { "functions" : false }], + "no-useless-call" : ["error"], + "no-useless-computed-key" : ["error"], + "no-useless-rename" : ["error"], + "prefer-rest-params" : ["error"], + "prefer-spread" : ["error"], + "prefer-template" : ["error"], + "semi" : ["error", "always"], + "vars-on-top" : ["error"] + } + } \ No newline at end of file diff --git a/.gitignore b/.gitignore index ad46b308..9c1caa6e 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,5 @@ typings/ # next.js build output .next + +dist \ No newline at end of file diff --git a/build/css-resolve.js b/build/css-resolve.js new file mode 100644 index 00000000..8d3684d7 --- /dev/null +++ b/build/css-resolve.js @@ -0,0 +1,91 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2019 (original work) Open Assessment Technologies SA ; + */ + +const { mkdirp, copyFile, access, constants } = require('fs-extra'); +const path = require('path'); +const { outputDir, srcDir, aliases } = require('./path'); + +/** + * resolve aliases in module name like ui or core + * @param {string} id - module id + * @returns {string} id with resolved alias + */ +const resolveAlias = id => { + for (let alias in aliases) { + if (aliases.hasOwnProperty(alias)) { + let afterAlias; + if (id.startsWith(alias) && (afterAlias = id.substring(alias.length))[0] === '/') { + return `${aliases[alias]}${afterAlias}`; + } + } + } + return id; +}; + +/** + * Copy CSS file and optionally the source map to output directory + * @param {string} cssFile + */ +const copyCss = async cssFile => { + const outputFile = path.resolve(outputDir, path.relative(srcDir, cssFile)); + + // check css file existance + try { + await access(cssFile, constants.F_OK); + } catch (err) { + console.error('\x1b[33m%s\x1b[0m', `${cssFile} was not found!`); // it is yellow + return; + } + + // create output directory if it is not exists + const outputFileDir = path.dirname(outputFile); + try { + await access(outputFileDir, constants.F_OK); + } catch (err) { + await mkdirp(outputFileDir); + } + + // copy css file + await copyFile(cssFile, outputFile); + + // copy map file if it exists + const mapFile = `${cssFile}.map`; + try { + await access(mapFile, constants.F_OK); + await copyFile(mapFile, `${outputFile}.map`)``; + } catch (e) {} +}; + +/** + * Css resolve plugin + */ +export default () => ({ + name: 'css-resolve', // this name will show up in warnings and errors + resolveId(source, importer) { + if (/\.css$/.test(source) && importer) { + const file = resolveAlias(source); + copyCss(file); + return { + id: `css!${source}`, + external: true, + moduleSideEffects: true + }; + } + return null; // other ids should be handled as usually + } +}); diff --git a/build/external-alias.js b/build/external-alias.js new file mode 100644 index 00000000..1ef432e4 --- /dev/null +++ b/build/external-alias.js @@ -0,0 +1,31 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2019 (original work) Open Assessment Technologies SA ; + */ + +export default (externals = []) => ({ + name: 'external-alias', // this name will show up in warnings and errors + resolveId(source, importer) { + if (importer && externals.find(external => source.startsWith(external))) { + return { + id: source, + external: true, + moduleSideEffects: true + }; + } + return null; // other ids should be handled as usually + } +}); diff --git a/build/path.js b/build/path.js new file mode 100644 index 00000000..7acb99e7 --- /dev/null +++ b/build/path.js @@ -0,0 +1,34 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2019 (original work) Open Assessment Technologies SA ; + */ + +/** + * This file contains path definitions for build scripts. + */ +const path = require('path'); +const rootPath = path.resolve(__dirname, '..'); +const srcDir = path.resolve(rootPath, 'src'); + +module.exports = { + rootPath, + srcDir, + testDir: path.resolve(rootPath, 'test'), + scssVendorDir: path.resolve(rootPath, 'scss'), + outputDir: path.resolve(rootPath, 'dist'), + testOutputDir: path.resolve(rootPath, 'test'), + aliases: { taoItems: srcDir } +}; diff --git a/build/postcss.config.js b/build/postcss.config.js new file mode 100644 index 00000000..58be6a72 --- /dev/null +++ b/build/postcss.config.js @@ -0,0 +1,29 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2019 (original work) Open Assessment Technologies SA ; + */ + +const path = require('path'); +const { rootPath } = require('./path'); + +module.exports = { + plugins: [ + require('@csstools/postcss-sass')({ + includePaths: [path.resolve(rootPath, 'scss')] + }), + require('autoprefixer') + ] +}; diff --git a/build/rollup.config.js b/build/rollup.config.js new file mode 100644 index 00000000..763dd5cf --- /dev/null +++ b/build/rollup.config.js @@ -0,0 +1,85 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2019 (original work) Open Assessment Technologies SA ; + */ + +import path from 'path'; +import glob from 'glob'; +import alias from 'rollup-plugin-alias'; +import handlebarsPlugin from 'rollup-plugin-handlebars-plus'; +import cssResolve from './css-resolve'; +import externalAlias from './external-alias'; +import resolve from 'rollup-plugin-node-resolve'; + +const { srcDir, outputDir, aliases } = require('./path'); +const Handlebars = require('handlebars'); + +/** + * Support of handlebars 1.3.0 + * TODO remove once migrated to hbs >= 3.0.0 + */ +const originalVisitor = Handlebars.Visitor; +Handlebars.Visitor = function() { + return originalVisitor.call(this); +}; +Handlebars.Visitor.prototype = Object.create(originalVisitor.prototype); +Handlebars.Visitor.prototype.accept = function() { + try { + originalVisitor.prototype.accept.apply(this, arguments); + } catch (e) {} +}; +/* --------------------------------------------------------- */ + +const inputs = glob.sync(path.join(srcDir, '**', '*.js')); + +/** + * Define all modules as external, so rollup won't bundle them together. + */ +const localExternals = inputs.map(input => `taoQtiItem/${path.relative(srcDir, input).replace(/\.js$/, '')}`); + +export default inputs.map(input => { + const name = path.relative(srcDir, input).replace(/\.js$/, ''); + const dir = path.dirname(path.relative(srcDir, input)); + + return { + input, + output: { + dir: path.join(outputDir, dir), + format: 'amd', + name + }, + external: ['jquery', 'lodash', ...localExternals], + plugins: [ + cssResolve(), + externalAlias(['core', 'util']), + alias({ + resolve: ['.js', '.json', '.tpl'], + ...aliases + }), + resolve(), + handlebarsPlugin({ + handlebars: { + id: 'handlebars', + options: { + sourceMap: false + }, + module: Handlebars + }, + templateExtension: '.tpl' + }) + ] + }; +}); diff --git a/build/scss.js b/build/scss.js new file mode 100644 index 00000000..1b39b35d --- /dev/null +++ b/build/scss.js @@ -0,0 +1,102 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2019 (original work) Open Assessment Technologies SA ; + */ + +const { mkdirp, writeFile, readFile, access, constants } = require('fs-extra'); +const path = require('path'); +const glob = require('glob'); +const postcss = require('postcss'); +const postcssScss = require('postcss-scss'); +const promiseLimit = require('promise-limit'); +const { srcDir, scssVendorDir, rootPath } = require('./path'); +const postcssConfig = require('./postcss.config'); + +const limit = promiseLimit(5); + +/** + * Build scss file with postcss and postcssScss plugin + * @param {string} scssFile + * @param {map: Boolean} options + */ +const buildScss = async scssFile => { + const outputFile = scssFile.replace(/([\/\.])scss(\/|$)/g, '$1css$2'); + + // load file content + let source; + try { + source = await readFile(scssFile, 'utf8'); + } catch (e) { + throw new Error(`File not found: ${scssFile}`); + } + + // compile scss + let compiledSource; + try { + compiledSource = await postcss(postcssConfig.plugins).process(source, { + syntax: postcssScss, + from: scssFile, + to: outputFile, + map: { annotation: true } + }); + } catch (e) { + console.error(e); + process.exit(-1); + } + + // write out css + return writeOutResult(compiledSource); +}; + +/** + * Write out compiled css and source map + * @param {LazyResult} result + */ +const writeOutResult = async result => { + const outputFile = result.opts.to; + const outputFileDir = path.dirname(outputFile); + + // create output directory if it doesn't exist + try { + await access(outputFileDir, constants.F_OK); + } catch (e) { + await mkdirp(outputFileDir); + } + + // write out css + await writeFile(outputFile, result.css, { flag: 'w' }); + + // write out map if exist + if (result.map) { + await writeFile(`${outputFile}.map`, result.map, { flag: 'w' }); + } +}; + +/** + * Build scss files to css files + */ +const scssDirectories = [scssVendorDir, srcDir]; + +glob( + path.join(rootPath, `+(${scssDirectories.map(dir => path.relative(rootPath, dir)).join('|')})`, '**', '[^_]*.scss'), + (err, files) => { + if (err) { + throw err; + } + + files.forEach(file => limit(() => buildScss(file))); + } +); diff --git a/build/testrunner.js b/build/testrunner.js new file mode 100644 index 00000000..e0f129d7 --- /dev/null +++ b/build/testrunner.js @@ -0,0 +1,79 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2019 (original work) Open Assessment Technologies SA ; + */ + +const glob = require('glob'); +const path = require('path'); +const { runQunitPuppeteer, printResultSummary, printFailedTests } = require('node-qunit-puppeteer'); +const promiseLimit = require('promise-limit'); + +const webServer = require('./webserver'); + +const { testDir } = require('./path'); + +const TESTNAME = process.argv[2] || '*'; + +let hasFailed = false; +const limit = promiseLimit(process.env.PARALLEL_TESTS || 5); + +webServer.then(({ host, port }) => + Promise.all( + glob.sync(path.join(testDir, '**', TESTNAME, '**', 'test.html')).map(testFile => { + const test = path.relative(testDir, testFile); + const qunitArgs = { + // Path to qunit tests suite + targetUrl: `http://${host}:${port}/test/${test}`, + // (optional, 30000 by default) global timeout for the tests suite + timeout: 30000, + // (optional, false by default) should the browser console be redirected or not + redirectConsole: false, + puppeteerArgs: [ + '--no-sandbox', + '--disable-gpu', + '--disable-popup-blocking', + '--autoplay-policy=no-user-gesture-required' + ] + }; + + return limit(() => + runQunitPuppeteer(qunitArgs) + .then(result => { + if (TESTNAME === '*') { + process.stdout.write('.'); + } else { + process.stdout.write(`${testFile} `); + printResultSummary(result, console); + console.log(); + } + + if (result.stats.failed > 0) { + console.log(`\n${testFile}`); + printFailedTests(result, console); + hasFailed = true; + } + }) + .catch(ex => { + console.error(testFile); + console.error(ex); + }) + ); + }) + ).then(() => { + console.log(); + process.exit(hasFailed ? -1 : 0); + }) +); diff --git a/build/webserver.js b/build/webserver.js new file mode 100644 index 00000000..e057cbcb --- /dev/null +++ b/build/webserver.js @@ -0,0 +1,54 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2019 (original work) Open Assessment Technologies SA ; + */ + +const HttpServer = require('http-server'); +const fs = require('fs'); +const path = require('path'); + +const HOST = process.env.HOST || '127.0.0.1'; +const PORT = process.env.PORT || '8082'; +const ROOT = path.resolve(__dirname, '..'); + +module.exports = new Promise(resolve => + new HttpServer.createServer({ + root: ROOT, + cache: -1, + before: [ + (req, res) => { + if (req.method === 'POST') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + fs.readFile(path.join(__dirname, '..', req.url), (err, data) => { + if (err) throw err; + res.end(data.toString()); + }); + } else { + res.emit('next'); + } + } + ] + }).listen(PORT, HOST, err => { + if (err) { + console.log(err); + process.exit(-1); + } + + console.log(`Server is listening on http://${HOST}:${PORT}/ and serving ${ROOT}`); + + resolve({ host: HOST, port: PORT }); + }) +); diff --git a/environment/config.js b/environment/config.js new file mode 100644 index 00000000..7abf074d --- /dev/null +++ b/environment/config.js @@ -0,0 +1,79 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2019 (original work) Open Assessment Technologies SA ; + */ + +requirejs.config({ + baseUrl: '/', + paths: { + css: '/node_modules/require-css/css', + // json: '/node_modules/requirejs-plugins/src/json', + // text: '/node_modules/text/text', + + /* TEST related */ + 'qunit-parameterize': '/environment/qunit2-parameterize', + qunit: '/node_modules/qunit/qunit', + 'test/taoItems': '/test', + + taoItems: '/dist', + core: '/node_modules/@oat-sa/tao-core-sdk/dist/core', + util: '/node_modules/@oat-sa/tao-core-sdk/dist/util', + lib: '/node_modules/@oat-sa/tao-core-libs/dist', + jquery: '/node_modules/jquery/jquery', + lodash: '/node_modules/lodash/lodash' + // moment: '/node_modules/moment/min/moment-with-locales', + // handlebars: '/node_modules/handlebars/dist/handlebars.amd', + + /* LIBS */ + // tpl: '/lib/tpl', + // 'jquery.autocomplete': '/node_modules/devbridge-autocomplete/dist/jquery.autocomplete', + // 'jquery.mockjax': '/node_modules/jquery-mockjax/dist/jquery.mockjax', + // 'jquery.fileDownload': '/lib/jquery.fileDownload', + // 'lib/popper/tooltip': '/node_modules/tooltip.js/dist/umd/tooltip', + // popper: '/node_modules/popper.js/dist/umd/popper', + // select2: '/node_modules/select2/select2', + // interact: '/node_modules/interactjs/dist/interact', + // 'lib/dompurify/purify': '/node_modules/dompurify/dist/purify', + // 'lib/gamp/gamp': '/node_modules/gamp/src/gamp', + // 'lib/flatpickr': '/node_modules/flatpickr/dist', + // 'lib/moo/moo': '/node_modules/moo/moo', + // 'lib/decimal/decimal': '/node_modules/decimal.js/decimal', + // 'lib/expr-eval/expr-eval': '/node_modules/@oat-sa/expr-eval/dist/bundle', + // iframeNotifier: '/lib/iframeNotifier', + // async: '/node_modules/async/lib/async', + // nouislider: '/lib/sliders/jquery.nouislider', + // helpers: '/lib/helpers', + // lib: '/lib', + /* LIBS END */ + }, + shim: { + 'qunit-parameterize': { + deps: ['qunit/qunit'] + } + }, + waitSeconds: 15 +}); + +define('qunitLibs', ['qunit/qunit', 'css!qunit/qunit.css']); +define('qunitEnv', ['qunitLibs', 'qunit-parameterize'], function() { + requirejs.config({ nodeIdCompat: true }); +}); + +define('context', ['module'], function(module) { + return module.config(); +}); + +define('i18n', [], () => text => text); diff --git a/environment/qunit2-parameterize.js b/environment/qunit2-parameterize.js new file mode 100644 index 00000000..21f4384e --- /dev/null +++ b/environment/qunit2-parameterize.js @@ -0,0 +1,172 @@ +/* + * Parameterize v 0.4 + * A QUnit Addon For Running Parameterized Tests + * https://github.com/AStepaniuk/qunit-parameterize + * Released under the MIT license. + */ +QUnit.extend(QUnit, { + cases: (function() { + 'use strict'; + var currentCases = null, + clone = function(testCase) { + var result = {}, + p = null; + + for (p in testCase) { + if (testCase.hasOwnProperty(p)) { + result[p] = testCase[p]; + } + } + + return result; + }, + + createTest = function(methodName, title, callback, parameters) { + + QUnit[methodName](title, function(assert) { + return callback.call(this, parameters, assert); + }); + }, + + iterateTestCases = function(methodName, title, callback) { + var i = 0, + parameters = null, + testCaseTitle = null; + + if (!currentCases || currentCases.length === 0) { + // setup test which will always fail + QUnit.test(title, function(assert) { + assert.ok(false, "No test cases are provided"); + }); + return; + } + + for (i = 0; i < currentCases.length; i += 1) { + parameters = currentCases[i]; + + testCaseTitle = title; + if (parameters.title) { + testCaseTitle += "[" + parameters.title + "]"; + } + + if (parameters._skip === true) { + methodName = 'skip'; + } + + createTest(methodName, testCaseTitle, callback, parameters); + } + }, + + getLength = function(arr) { + return arr ? arr.length : 0; + }, + + getItem = function(arr, idx) { + return arr ? arr[idx] : undefined; + }, + + mix = function(testCase, mixData) { + var result = null, + p = null; + + if (testCase && mixData) { + result = clone(testCase); + + for (p in mixData) { + if (mixData.hasOwnProperty(p)) { + if (p !== "title") { + if (!(result.hasOwnProperty(p))) { + result[p] = mixData[p]; + } + } else { + result[p] = [result[p], mixData[p]].join(""); + } + } + } + + } else if (testCase) { + result = testCase; + } else if (mixData) { + result = mixData; + } else { + // return null or undefined whatever testCase is + result = testCase; + } + + return result; + }; + + return { + + init: function(testCasesList) { + currentCases = testCasesList; + return this; + }, + + sequential: function(addData) { + var casesLength = getLength(currentCases), + addDataLength = getLength(addData), + length = casesLength > addDataLength ? casesLength : addDataLength, + newCases = [], + i = 0, + currentCaseI = null, + dataI = null, + newCase = null; + + for (i = 0; i < length; i += 1) { + currentCaseI = getItem(currentCases, i); + dataI = getItem(addData, i); + newCase = mix(currentCaseI, dataI); + + if (newCase) { + newCases.push(newCase); + } + } + + currentCases = newCases; + + return this; + }, + + combinatorial: function(mixData) { + var current = (currentCases && currentCases.length > 0) ? currentCases : [null], + currentLength = current.length, + mixDataLength = 0, + newCases = [], + i = 0, + j = 0, + currentCaseI = null, + dataJ = null, + newCase = null; + + mixData = (mixData && mixData.length > 0) ? mixData : [null]; + mixDataLength = mixData.length; + + for (i = 0; i < currentLength; i += 1) { + for (j = 0; j < mixDataLength; j += 1) { + currentCaseI = current[i]; + dataJ = mixData[j]; + newCase = mix(currentCaseI, dataJ); + + if (newCase) { + newCases.push(newCase); + } + } + } + + currentCases = newCases; + + return this; + }, + + test: function(title, callback) { + iterateTestCases("test", title, callback); + return this; + }, + + getCurrentTestCases: function () { + return currentCases; + } + }; + }()) +}); \ No newline at end of file diff --git a/environment/require.js b/environment/require.js new file mode 100644 index 00000000..acd22384 --- /dev/null +++ b/environment/require.js @@ -0,0 +1,2145 @@ +/** vim: et:ts=4:sw=4:sts=4 + * @license RequireJS 2.3.6 Copyright jQuery Foundation and other contributors. + * Released under MIT license, https://github.com/requirejs/requirejs/blob/master/LICENSE + */ +//Not using strict: uneven strict support in browsers, #392, and causes +//problems with requirejs.exec()/transpiler plugins that may not be strict. +/*jslint regexp: true, nomen: true, sloppy: true */ +/*global window, navigator, document, importScripts, setTimeout, opera */ + +var requirejs, require, define; +(function (global, setTimeout) { + var req, s, head, baseElement, dataMain, src, + interactiveScript, currentlyAddingScript, mainScript, subPath, + version = '2.3.6', + commentRegExp = /\/\*[\s\S]*?\*\/|([^:"'=]|^)\/\/.*$/mg, + cjsRequireRegExp = /[^.]\s*require\s*\(\s*["']([^'"\s]+)["']\s*\)/g, + jsSuffixRegExp = /\.js$/, + currDirRegExp = /^\.\//, + op = Object.prototype, + ostring = op.toString, + hasOwn = op.hasOwnProperty, + isBrowser = !!(typeof window !== 'undefined' && typeof navigator !== 'undefined' && window.document), + isWebWorker = !isBrowser && typeof importScripts !== 'undefined', + //PS3 indicates loaded and complete, but need to wait for complete + //specifically. Sequence is 'loading', 'loaded', execution, + // then 'complete'. The UA check is unfortunate, but not sure how + //to feature test w/o causing perf issues. + readyRegExp = isBrowser && navigator.platform === 'PLAYSTATION 3' ? + /^complete$/ : /^(complete|loaded)$/, + defContextName = '_', + //Oh the tragedy, detecting opera. See the usage of isOpera for reason. + isOpera = typeof opera !== 'undefined' && opera.toString() === '[object Opera]', + contexts = {}, + cfg = {}, + globalDefQueue = [], + useInteractive = false; + + //Could match something like ')//comment', do not lose the prefix to comment. + function commentReplace(match, singlePrefix) { + return singlePrefix || ''; + } + + function isFunction(it) { + return ostring.call(it) === '[object Function]'; + } + + function isArray(it) { + return ostring.call(it) === '[object Array]'; + } + + /** + * Helper function for iterating over an array. If the func returns + * a true value, it will break out of the loop. + */ + function each(ary, func) { + if (ary) { + var i; + for (i = 0; i < ary.length; i += 1) { + if (ary[i] && func(ary[i], i, ary)) { + break; + } + } + } + } + + /** + * Helper function for iterating over an array backwards. If the func + * returns a true value, it will break out of the loop. + */ + function eachReverse(ary, func) { + if (ary) { + var i; + for (i = ary.length - 1; i > -1; i -= 1) { + if (ary[i] && func(ary[i], i, ary)) { + break; + } + } + } + } + + function hasProp(obj, prop) { + return hasOwn.call(obj, prop); + } + + function getOwn(obj, prop) { + return hasProp(obj, prop) && obj[prop]; + } + + /** + * Cycles over properties in an object and calls a function for each + * property value. If the function returns a truthy value, then the + * iteration is stopped. + */ + function eachProp(obj, func) { + var prop; + for (prop in obj) { + if (hasProp(obj, prop)) { + if (func(obj[prop], prop)) { + break; + } + } + } + } + + /** + * Simple function to mix in properties from source into target, + * but only if target does not already have a property of the same name. + */ + function mixin(target, source, force, deepStringMixin) { + if (source) { + eachProp(source, function (value, prop) { + if (force || !hasProp(target, prop)) { + if (deepStringMixin && typeof value === 'object' && value && + !isArray(value) && !isFunction(value) && + !(value instanceof RegExp)) { + + if (!target[prop]) { + target[prop] = {}; + } + mixin(target[prop], value, force, deepStringMixin); + } else { + target[prop] = value; + } + } + }); + } + return target; + } + + //Similar to Function.prototype.bind, but the 'this' object is specified + //first, since it is easier to read/figure out what 'this' will be. + function bind(obj, fn) { + return function () { + return fn.apply(obj, arguments); + }; + } + + function scripts() { + return document.getElementsByTagName('script'); + } + + function defaultOnError(err) { + throw err; + } + + //Allow getting a global that is expressed in + //dot notation, like 'a.b.c'. + function getGlobal(value) { + if (!value) { + return value; + } + var g = global; + each(value.split('.'), function (part) { + g = g[part]; + }); + return g; + } + + /** + * Constructs an error with a pointer to an URL with more information. + * @param {String} id the error ID that maps to an ID on a web page. + * @param {String} message human readable error. + * @param {Error} [err] the original error, if there is one. + * + * @returns {Error} + */ + function makeError(id, msg, err, requireModules) { + var e = new Error(msg + '\nhttps://requirejs.org/docs/errors.html#' + id); + e.requireType = id; + e.requireModules = requireModules; + if (err) { + e.originalError = err; + } + return e; + } + + if (typeof define !== 'undefined') { + //If a define is already in play via another AMD loader, + //do not overwrite. + return; + } + + if (typeof requirejs !== 'undefined') { + if (isFunction(requirejs)) { + //Do not overwrite an existing requirejs instance. + return; + } + cfg = requirejs; + requirejs = undefined; + } + + //Allow for a require config object + if (typeof require !== 'undefined' && !isFunction(require)) { + //assume it is a config object. + cfg = require; + require = undefined; + } + + function newContext(contextName) { + var inCheckLoaded, Module, context, handlers, + checkLoadedTimeoutId, + config = { + //Defaults. Do not set a default for map + //config to speed up normalize(), which + //will run faster if there is no default. + waitSeconds: 7, + baseUrl: './', + paths: {}, + bundles: {}, + pkgs: {}, + shim: {}, + config: {} + }, + registry = {}, + //registry of just enabled modules, to speed + //cycle breaking code when lots of modules + //are registered, but not activated. + enabledRegistry = {}, + undefEvents = {}, + defQueue = [], + defined = {}, + urlFetched = {}, + bundlesMap = {}, + requireCounter = 1, + unnormalizedCounter = 1; + + /** + * Trims the . and .. from an array of path segments. + * It will keep a leading path segment if a .. will become + * the first path segment, to help with module name lookups, + * which act like paths, but can be remapped. But the end result, + * all paths that use this function should look normalized. + * NOTE: this method MODIFIES the input array. + * @param {Array} ary the array of path segments. + */ + function trimDots(ary) { + var i, part; + for (i = 0; i < ary.length; i++) { + part = ary[i]; + if (part === '.') { + ary.splice(i, 1); + i -= 1; + } else if (part === '..') { + // If at the start, or previous value is still .., + // keep them so that when converted to a path it may + // still work when converted to a path, even though + // as an ID it is less than ideal. In larger point + // releases, may be better to just kick out an error. + if (i === 0 || (i === 1 && ary[2] === '..') || ary[i - 1] === '..') { + continue; + } else if (i > 0) { + ary.splice(i - 1, 2); + i -= 2; + } + } + } + } + + /** + * Given a relative module name, like ./something, normalize it to + * a real name that can be mapped to a path. + * @param {String} name the relative name + * @param {String} baseName a real name that the name arg is relative + * to. + * @param {Boolean} applyMap apply the map config to the value. Should + * only be done if this normalization is for a dependency ID. + * @returns {String} normalized name + */ + function normalize(name, baseName, applyMap) { + var pkgMain, mapValue, nameParts, i, j, nameSegment, lastIndex, + foundMap, foundI, foundStarMap, starI, normalizedBaseParts, + baseParts = (baseName && baseName.split('/')), + map = config.map, + starMap = map && map['*']; + + //Adjust any relative paths. + if (name) { + name = name.split('/'); + lastIndex = name.length - 1; + + // If wanting node ID compatibility, strip .js from end + // of IDs. Have to do this here, and not in nameToUrl + // because node allows either .js or non .js to map + // to same file. + if (config.nodeIdCompat && jsSuffixRegExp.test(name[lastIndex])) { + name[lastIndex] = name[lastIndex].replace(jsSuffixRegExp, ''); + } + + // Starts with a '.' so need the baseName + if (name[0].charAt(0) === '.' && baseParts) { + //Convert baseName to array, and lop off the last part, + //so that . matches that 'directory' and not name of the baseName's + //module. For instance, baseName of 'one/two/three', maps to + //'one/two/three.js', but we want the directory, 'one/two' for + //this normalization. + normalizedBaseParts = baseParts.slice(0, baseParts.length - 1); + name = normalizedBaseParts.concat(name); + } + + trimDots(name); + name = name.join('/'); + } + + //Apply map config if available. + if (applyMap && map && (baseParts || starMap)) { + nameParts = name.split('/'); + + outerLoop: for (i = nameParts.length; i > 0; i -= 1) { + nameSegment = nameParts.slice(0, i).join('/'); + + if (baseParts) { + //Find the longest baseName segment match in the config. + //So, do joins on the biggest to smallest lengths of baseParts. + for (j = baseParts.length; j > 0; j -= 1) { + mapValue = getOwn(map, baseParts.slice(0, j).join('/')); + + //baseName segment has config, find if it has one for + //this name. + if (mapValue) { + mapValue = getOwn(mapValue, nameSegment); + if (mapValue) { + //Match, update name to the new value. + foundMap = mapValue; + foundI = i; + break outerLoop; + } + } + } + } + + //Check for a star map match, but just hold on to it, + //if there is a shorter segment match later in a matching + //config, then favor over this star map. + if (!foundStarMap && starMap && getOwn(starMap, nameSegment)) { + foundStarMap = getOwn(starMap, nameSegment); + starI = i; + } + } + + if (!foundMap && foundStarMap) { + foundMap = foundStarMap; + foundI = starI; + } + + if (foundMap) { + nameParts.splice(0, foundI, foundMap); + name = nameParts.join('/'); + } + } + + // If the name points to a package's name, use + // the package main instead. + pkgMain = getOwn(config.pkgs, name); + + return pkgMain ? pkgMain : name; + } + + function removeScript(name) { + if (isBrowser) { + each(scripts(), function (scriptNode) { + if (scriptNode.getAttribute('data-requiremodule') === name && + scriptNode.getAttribute('data-requirecontext') === context.contextName) { + scriptNode.parentNode.removeChild(scriptNode); + return true; + } + }); + } + } + + function hasPathFallback(id) { + var pathConfig = getOwn(config.paths, id); + if (pathConfig && isArray(pathConfig) && pathConfig.length > 1) { + //Pop off the first array value, since it failed, and + //retry + pathConfig.shift(); + context.require.undef(id); + + //Custom require that does not do map translation, since + //ID is "absolute", already mapped/resolved. + context.makeRequire(null, { + skipMap: true + })([id]); + + return true; + } + } + + //Turns a plugin!resource to [plugin, resource] + //with the plugin being undefined if the name + //did not have a plugin prefix. + function splitPrefix(name) { + var prefix, + index = name ? name.indexOf('!') : -1; + if (index > -1) { + prefix = name.substring(0, index); + name = name.substring(index + 1, name.length); + } + return [prefix, name]; + } + + /** + * Creates a module mapping that includes plugin prefix, module + * name, and path. If parentModuleMap is provided it will + * also normalize the name via require.normalize() + * + * @param {String} name the module name + * @param {String} [parentModuleMap] parent module map + * for the module name, used to resolve relative names. + * @param {Boolean} isNormalized: is the ID already normalized. + * This is true if this call is done for a define() module ID. + * @param {Boolean} applyMap: apply the map config to the ID. + * Should only be true if this map is for a dependency. + * + * @returns {Object} + */ + function makeModuleMap(name, parentModuleMap, isNormalized, applyMap) { + var url, pluginModule, suffix, nameParts, + prefix = null, + parentName = parentModuleMap ? parentModuleMap.name : null, + originalName = name, + isDefine = true, + normalizedName = ''; + + //If no name, then it means it is a require call, generate an + //internal name. + if (!name) { + isDefine = false; + name = '_@r' + (requireCounter += 1); + } + + nameParts = splitPrefix(name); + prefix = nameParts[0]; + name = nameParts[1]; + + if (prefix) { + prefix = normalize(prefix, parentName, applyMap); + pluginModule = getOwn(defined, prefix); + } + + //Account for relative paths if there is a base name. + if (name) { + if (prefix) { + if (isNormalized) { + normalizedName = name; + } else if (pluginModule && pluginModule.normalize) { + //Plugin is loaded, use its normalize method. + normalizedName = pluginModule.normalize(name, function (name) { + return normalize(name, parentName, applyMap); + }); + } else { + // If nested plugin references, then do not try to + // normalize, as it will not normalize correctly. This + // places a restriction on resourceIds, and the longer + // term solution is not to normalize until plugins are + // loaded and all normalizations to allow for async + // loading of a loader plugin. But for now, fixes the + // common uses. Details in #1131 + normalizedName = name.indexOf('!') === -1 ? + normalize(name, parentName, applyMap) : + name; + } + } else { + //A regular module. + normalizedName = normalize(name, parentName, applyMap); + + //Normalized name may be a plugin ID due to map config + //application in normalize. The map config values must + //already be normalized, so do not need to redo that part. + nameParts = splitPrefix(normalizedName); + prefix = nameParts[0]; + normalizedName = nameParts[1]; + isNormalized = true; + + url = context.nameToUrl(normalizedName); + } + } + + //If the id is a plugin id that cannot be determined if it needs + //normalization, stamp it with a unique ID so two matching relative + //ids that may conflict can be separate. + suffix = prefix && !pluginModule && !isNormalized ? + '_unnormalized' + (unnormalizedCounter += 1) : + ''; + + return { + prefix: prefix, + name: normalizedName, + parentMap: parentModuleMap, + unnormalized: !!suffix, + url: url, + originalName: originalName, + isDefine: isDefine, + id: (prefix ? + prefix + '!' + normalizedName : + normalizedName) + suffix + }; + } + + function getModule(depMap) { + var id = depMap.id, + mod = getOwn(registry, id); + + if (!mod) { + mod = registry[id] = new context.Module(depMap); + } + + return mod; + } + + function on(depMap, name, fn) { + var id = depMap.id, + mod = getOwn(registry, id); + + if (hasProp(defined, id) && + (!mod || mod.defineEmitComplete)) { + if (name === 'defined') { + fn(defined[id]); + } + } else { + mod = getModule(depMap); + if (mod.error && name === 'error') { + fn(mod.error); + } else { + mod.on(name, fn); + } + } + } + + function onError(err, errback) { + var ids = err.requireModules, + notified = false; + + if (errback) { + errback(err); + } else { + each(ids, function (id) { + var mod = getOwn(registry, id); + if (mod) { + //Set error on module, so it skips timeout checks. + mod.error = err; + if (mod.events.error) { + notified = true; + mod.emit('error', err); + } + } + }); + + if (!notified) { + req.onError(err); + } + } + } + + /** + * Internal method to transfer globalQueue items to this context's + * defQueue. + */ + function takeGlobalQueue() { + //Push all the globalDefQueue items into the context's defQueue + if (globalDefQueue.length) { + each(globalDefQueue, function(queueItem) { + var id = queueItem[0]; + if (typeof id === 'string') { + context.defQueueMap[id] = true; + } + defQueue.push(queueItem); + }); + globalDefQueue = []; + } + } + + handlers = { + 'require': function (mod) { + if (mod.require) { + return mod.require; + } else { + return (mod.require = context.makeRequire(mod.map)); + } + }, + 'exports': function (mod) { + mod.usingExports = true; + if (mod.map.isDefine) { + if (mod.exports) { + return (defined[mod.map.id] = mod.exports); + } else { + return (mod.exports = defined[mod.map.id] = {}); + } + } + }, + 'module': function (mod) { + if (mod.module) { + return mod.module; + } else { + return (mod.module = { + id: mod.map.id, + uri: mod.map.url, + config: function () { + return getOwn(config.config, mod.map.id) || {}; + }, + exports: mod.exports || (mod.exports = {}) + }); + } + } + }; + + function cleanRegistry(id) { + //Clean up machinery used for waiting modules. + delete registry[id]; + delete enabledRegistry[id]; + } + + function breakCycle(mod, traced, processed) { + var id = mod.map.id; + + if (mod.error) { + mod.emit('error', mod.error); + } else { + traced[id] = true; + each(mod.depMaps, function (depMap, i) { + var depId = depMap.id, + dep = getOwn(registry, depId); + + //Only force things that have not completed + //being defined, so still in the registry, + //and only if it has not been matched up + //in the module already. + if (dep && !mod.depMatched[i] && !processed[depId]) { + if (getOwn(traced, depId)) { + mod.defineDep(i, defined[depId]); + mod.check(); //pass false? + } else { + breakCycle(dep, traced, processed); + } + } + }); + processed[id] = true; + } + } + + function checkLoaded() { + var err, usingPathFallback, + waitInterval = config.waitSeconds * 1000, + //It is possible to disable the wait interval by using waitSeconds of 0. + expired = waitInterval && (context.startTime + waitInterval) < new Date().getTime(), + noLoads = [], + reqCalls = [], + stillLoading = false, + needCycleCheck = true; + + //Do not bother if this call was a result of a cycle break. + if (inCheckLoaded) { + return; + } + + inCheckLoaded = true; + + //Figure out the state of all the modules. + eachProp(enabledRegistry, function (mod) { + var map = mod.map, + modId = map.id; + + //Skip things that are not enabled or in error state. + if (!mod.enabled) { + return; + } + + if (!map.isDefine) { + reqCalls.push(mod); + } + + if (!mod.error) { + //If the module should be executed, and it has not + //been inited and time is up, remember it. + if (!mod.inited && expired) { + if (hasPathFallback(modId)) { + usingPathFallback = true; + stillLoading = true; + } else { + noLoads.push(modId); + removeScript(modId); + } + } else if (!mod.inited && mod.fetched && map.isDefine) { + stillLoading = true; + if (!map.prefix) { + //No reason to keep looking for unfinished + //loading. If the only stillLoading is a + //plugin resource though, keep going, + //because it may be that a plugin resource + //is waiting on a non-plugin cycle. + return (needCycleCheck = false); + } + } + } + }); + + if (expired && noLoads.length) { + //If wait time expired, throw error of unloaded modules. + err = makeError('timeout', 'Load timeout for modules: ' + noLoads, null, noLoads); + err.contextName = context.contextName; + return onError(err); + } + + //Not expired, check for a cycle. + if (needCycleCheck) { + each(reqCalls, function (mod) { + breakCycle(mod, {}, {}); + }); + } + + //If still waiting on loads, and the waiting load is something + //other than a plugin resource, or there are still outstanding + //scripts, then just try back later. + if ((!expired || usingPathFallback) && stillLoading) { + //Something is still waiting to load. Wait for it, but only + //if a timeout is not already in effect. + if ((isBrowser || isWebWorker) && !checkLoadedTimeoutId) { + checkLoadedTimeoutId = setTimeout(function () { + checkLoadedTimeoutId = 0; + checkLoaded(); + }, 50); + } + } + + inCheckLoaded = false; + } + + Module = function (map) { + this.events = getOwn(undefEvents, map.id) || {}; + this.map = map; + this.shim = getOwn(config.shim, map.id); + this.depExports = []; + this.depMaps = []; + this.depMatched = []; + this.pluginMaps = {}; + this.depCount = 0; + + /* this.exports this.factory + this.depMaps = [], + this.enabled, this.fetched + */ + }; + + Module.prototype = { + init: function (depMaps, factory, errback, options) { + options = options || {}; + + //Do not do more inits if already done. Can happen if there + //are multiple define calls for the same module. That is not + //a normal, common case, but it is also not unexpected. + if (this.inited) { + return; + } + + this.factory = factory; + + if (errback) { + //Register for errors on this module. + this.on('error', errback); + } else if (this.events.error) { + //If no errback already, but there are error listeners + //on this module, set up an errback to pass to the deps. + errback = bind(this, function (err) { + this.emit('error', err); + }); + } + + //Do a copy of the dependency array, so that + //source inputs are not modified. For example + //"shim" deps are passed in here directly, and + //doing a direct modification of the depMaps array + //would affect that config. + this.depMaps = depMaps && depMaps.slice(0); + + this.errback = errback; + + //Indicate this module has be initialized + this.inited = true; + + this.ignore = options.ignore; + + //Could have option to init this module in enabled mode, + //or could have been previously marked as enabled. However, + //the dependencies are not known until init is called. So + //if enabled previously, now trigger dependencies as enabled. + if (options.enabled || this.enabled) { + //Enable this module and dependencies. + //Will call this.check() + this.enable(); + } else { + this.check(); + } + }, + + defineDep: function (i, depExports) { + //Because of cycles, defined callback for a given + //export can be called more than once. + if (!this.depMatched[i]) { + this.depMatched[i] = true; + this.depCount -= 1; + this.depExports[i] = depExports; + } + }, + + fetch: function () { + if (this.fetched) { + return; + } + this.fetched = true; + + context.startTime = (new Date()).getTime(); + + var map = this.map; + + //If the manager is for a plugin managed resource, + //ask the plugin to load it now. + if (this.shim) { + context.makeRequire(this.map, { + enableBuildCallback: true + })(this.shim.deps || [], bind(this, function () { + return map.prefix ? this.callPlugin() : this.load(); + })); + } else { + //Regular dependency. + return map.prefix ? this.callPlugin() : this.load(); + } + }, + + load: function () { + var url = this.map.url; + + //Regular dependency. + if (!urlFetched[url]) { + urlFetched[url] = true; + context.load(this.map.id, url); + } + }, + + /** + * Checks if the module is ready to define itself, and if so, + * define it. + */ + check: function () { + if (!this.enabled || this.enabling) { + return; + } + + var err, cjsModule, + id = this.map.id, + depExports = this.depExports, + exports = this.exports, + factory = this.factory; + + if (!this.inited) { + // Only fetch if not already in the defQueue. + if (!hasProp(context.defQueueMap, id)) { + this.fetch(); + } + } else if (this.error) { + this.emit('error', this.error); + } else if (!this.defining) { + //The factory could trigger another require call + //that would result in checking this module to + //define itself again. If already in the process + //of doing that, skip this work. + this.defining = true; + + if (this.depCount < 1 && !this.defined) { + if (isFunction(factory)) { + //If there is an error listener, favor passing + //to that instead of throwing an error. However, + //only do it for define()'d modules. require + //errbacks should not be called for failures in + //their callbacks (#699). However if a global + //onError is set, use that. + if ((this.events.error && this.map.isDefine) || + req.onError !== defaultOnError) { + try { + exports = context.execCb(id, factory, depExports, exports); + } catch (e) { + err = e; + } + } else { + exports = context.execCb(id, factory, depExports, exports); + } + + // Favor return value over exports. If node/cjs in play, + // then will not have a return value anyway. Favor + // module.exports assignment over exports object. + if (this.map.isDefine && exports === undefined) { + cjsModule = this.module; + if (cjsModule) { + exports = cjsModule.exports; + } else if (this.usingExports) { + //exports already set the defined value. + exports = this.exports; + } + } + + if (err) { + err.requireMap = this.map; + err.requireModules = this.map.isDefine ? [this.map.id] : null; + err.requireType = this.map.isDefine ? 'define' : 'require'; + return onError((this.error = err)); + } + + } else { + //Just a literal value + exports = factory; + } + + this.exports = exports; + + if (this.map.isDefine && !this.ignore) { + defined[id] = exports; + + if (req.onResourceLoad) { + var resLoadMaps = []; + each(this.depMaps, function (depMap) { + resLoadMaps.push(depMap.normalizedMap || depMap); + }); + req.onResourceLoad(context, this.map, resLoadMaps); + } + } + + //Clean up + cleanRegistry(id); + + this.defined = true; + } + + //Finished the define stage. Allow calling check again + //to allow define notifications below in the case of a + //cycle. + this.defining = false; + + if (this.defined && !this.defineEmitted) { + this.defineEmitted = true; + this.emit('defined', this.exports); + this.defineEmitComplete = true; + } + + } + }, + + callPlugin: function () { + var map = this.map, + id = map.id, + //Map already normalized the prefix. + pluginMap = makeModuleMap(map.prefix); + + //Mark this as a dependency for this plugin, so it + //can be traced for cycles. + this.depMaps.push(pluginMap); + + on(pluginMap, 'defined', bind(this, function (plugin) { + var load, normalizedMap, normalizedMod, + bundleId = getOwn(bundlesMap, this.map.id), + name = this.map.name, + parentName = this.map.parentMap ? this.map.parentMap.name : null, + localRequire = context.makeRequire(map.parentMap, { + enableBuildCallback: true + }); + + //If current map is not normalized, wait for that + //normalized name to load instead of continuing. + if (this.map.unnormalized) { + //Normalize the ID if the plugin allows it. + if (plugin.normalize) { + name = plugin.normalize(name, function (name) { + return normalize(name, parentName, true); + }) || ''; + } + + //prefix and name should already be normalized, no need + //for applying map config again either. + normalizedMap = makeModuleMap(map.prefix + '!' + name, + this.map.parentMap, + true); + on(normalizedMap, + 'defined', bind(this, function (value) { + this.map.normalizedMap = normalizedMap; + this.init([], function () { return value; }, null, { + enabled: true, + ignore: true + }); + })); + + normalizedMod = getOwn(registry, normalizedMap.id); + if (normalizedMod) { + //Mark this as a dependency for this plugin, so it + //can be traced for cycles. + this.depMaps.push(normalizedMap); + + if (this.events.error) { + normalizedMod.on('error', bind(this, function (err) { + this.emit('error', err); + })); + } + normalizedMod.enable(); + } + + return; + } + + //If a paths config, then just load that file instead to + //resolve the plugin, as it is built into that paths layer. + if (bundleId) { + this.map.url = context.nameToUrl(bundleId); + this.load(); + return; + } + + load = bind(this, function (value) { + this.init([], function () { return value; }, null, { + enabled: true + }); + }); + + load.error = bind(this, function (err) { + this.inited = true; + this.error = err; + err.requireModules = [id]; + + //Remove temp unnormalized modules for this module, + //since they will never be resolved otherwise now. + eachProp(registry, function (mod) { + if (mod.map.id.indexOf(id + '_unnormalized') === 0) { + cleanRegistry(mod.map.id); + } + }); + + onError(err); + }); + + //Allow plugins to load other code without having to know the + //context or how to 'complete' the load. + load.fromText = bind(this, function (text, textAlt) { + /*jslint evil: true */ + var moduleName = map.name, + moduleMap = makeModuleMap(moduleName), + hasInteractive = useInteractive; + + //As of 2.1.0, support just passing the text, to reinforce + //fromText only being called once per resource. Still + //support old style of passing moduleName but discard + //that moduleName in favor of the internal ref. + if (textAlt) { + text = textAlt; + } + + //Turn off interactive script matching for IE for any define + //calls in the text, then turn it back on at the end. + if (hasInteractive) { + useInteractive = false; + } + + //Prime the system by creating a module instance for + //it. + getModule(moduleMap); + + //Transfer any config to this other module. + if (hasProp(config.config, id)) { + config.config[moduleName] = config.config[id]; + } + + try { + req.exec(text); + } catch (e) { + return onError(makeError('fromtexteval', + 'fromText eval for ' + id + + ' failed: ' + e, + e, + [id])); + } + + if (hasInteractive) { + useInteractive = true; + } + + //Mark this as a dependency for the plugin + //resource + this.depMaps.push(moduleMap); + + //Support anonymous modules. + context.completeLoad(moduleName); + + //Bind the value of that module to the value for this + //resource ID. + localRequire([moduleName], load); + }); + + //Use parentName here since the plugin's name is not reliable, + //could be some weird string with no path that actually wants to + //reference the parentName's path. + plugin.load(map.name, localRequire, load, config); + })); + + context.enable(pluginMap, this); + this.pluginMaps[pluginMap.id] = pluginMap; + }, + + enable: function () { + enabledRegistry[this.map.id] = this; + this.enabled = true; + + //Set flag mentioning that the module is enabling, + //so that immediate calls to the defined callbacks + //for dependencies do not trigger inadvertent load + //with the depCount still being zero. + this.enabling = true; + + //Enable each dependency + each(this.depMaps, bind(this, function (depMap, i) { + var id, mod, handler; + + if (typeof depMap === 'string') { + //Dependency needs to be converted to a depMap + //and wired up to this module. + depMap = makeModuleMap(depMap, + (this.map.isDefine ? this.map : this.map.parentMap), + false, + !this.skipMap); + this.depMaps[i] = depMap; + + handler = getOwn(handlers, depMap.id); + + if (handler) { + this.depExports[i] = handler(this); + return; + } + + this.depCount += 1; + + on(depMap, 'defined', bind(this, function (depExports) { + if (this.undefed) { + return; + } + this.defineDep(i, depExports); + this.check(); + })); + + if (this.errback) { + on(depMap, 'error', bind(this, this.errback)); + } else if (this.events.error) { + // No direct errback on this module, but something + // else is listening for errors, so be sure to + // propagate the error correctly. + on(depMap, 'error', bind(this, function(err) { + this.emit('error', err); + })); + } + } + + id = depMap.id; + mod = registry[id]; + + //Skip special modules like 'require', 'exports', 'module' + //Also, don't call enable if it is already enabled, + //important in circular dependency cases. + if (!hasProp(handlers, id) && mod && !mod.enabled) { + context.enable(depMap, this); + } + })); + + //Enable each plugin that is used in + //a dependency + eachProp(this.pluginMaps, bind(this, function (pluginMap) { + var mod = getOwn(registry, pluginMap.id); + if (mod && !mod.enabled) { + context.enable(pluginMap, this); + } + })); + + this.enabling = false; + + this.check(); + }, + + on: function (name, cb) { + var cbs = this.events[name]; + if (!cbs) { + cbs = this.events[name] = []; + } + cbs.push(cb); + }, + + emit: function (name, evt) { + each(this.events[name], function (cb) { + cb(evt); + }); + if (name === 'error') { + //Now that the error handler was triggered, remove + //the listeners, since this broken Module instance + //can stay around for a while in the registry. + delete this.events[name]; + } + } + }; + + function callGetModule(args) { + //Skip modules already defined. + if (!hasProp(defined, args[0])) { + getModule(makeModuleMap(args[0], null, true)).init(args[1], args[2]); + } + } + + function removeListener(node, func, name, ieName) { + //Favor detachEvent because of IE9 + //issue, see attachEvent/addEventListener comment elsewhere + //in this file. + if (node.detachEvent && !isOpera) { + //Probably IE. If not it will throw an error, which will be + //useful to know. + if (ieName) { + node.detachEvent(ieName, func); + } + } else { + node.removeEventListener(name, func, false); + } + } + + /** + * Given an event from a script node, get the requirejs info from it, + * and then removes the event listeners on the node. + * @param {Event} evt + * @returns {Object} + */ + function getScriptData(evt) { + //Using currentTarget instead of target for Firefox 2.0's sake. Not + //all old browsers will be supported, but this one was easy enough + //to support and still makes sense. + var node = evt.currentTarget || evt.srcElement; + + //Remove the listeners once here. + removeListener(node, context.onScriptLoad, 'load', 'onreadystatechange'); + removeListener(node, context.onScriptError, 'error'); + + return { + node: node, + id: node && node.getAttribute('data-requiremodule') + }; + } + + function intakeDefines() { + var args; + + //Any defined modules in the global queue, intake them now. + takeGlobalQueue(); + + //Make sure any remaining defQueue items get properly processed. + while (defQueue.length) { + args = defQueue.shift(); + if (args[0] === null) { + return onError(makeError('mismatch', 'Mismatched anonymous define() module: ' + + args[args.length - 1])); + } else { + //args are id, deps, factory. Should be normalized by the + //define() function. + callGetModule(args); + } + } + context.defQueueMap = {}; + } + + context = { + config: config, + contextName: contextName, + registry: registry, + defined: defined, + urlFetched: urlFetched, + defQueue: defQueue, + defQueueMap: {}, + Module: Module, + makeModuleMap: makeModuleMap, + nextTick: req.nextTick, + onError: onError, + + /** + * Set a configuration for the context. + * @param {Object} cfg config object to integrate. + */ + configure: function (cfg) { + //Make sure the baseUrl ends in a slash. + if (cfg.baseUrl) { + if (cfg.baseUrl.charAt(cfg.baseUrl.length - 1) !== '/') { + cfg.baseUrl += '/'; + } + } + + // Convert old style urlArgs string to a function. + if (typeof cfg.urlArgs === 'string') { + var urlArgs = cfg.urlArgs; + cfg.urlArgs = function(id, url) { + return (url.indexOf('?') === -1 ? '?' : '&') + urlArgs; + }; + } + + //Save off the paths since they require special processing, + //they are additive. + var shim = config.shim, + objs = { + paths: true, + bundles: true, + config: true, + map: true + }; + + eachProp(cfg, function (value, prop) { + if (objs[prop]) { + if (!config[prop]) { + config[prop] = {}; + } + mixin(config[prop], value, true, true); + } else { + config[prop] = value; + } + }); + + //Reverse map the bundles + if (cfg.bundles) { + eachProp(cfg.bundles, function (value, prop) { + each(value, function (v) { + if (v !== prop) { + bundlesMap[v] = prop; + } + }); + }); + } + + //Merge shim + if (cfg.shim) { + eachProp(cfg.shim, function (value, id) { + //Normalize the structure + if (isArray(value)) { + value = { + deps: value + }; + } + if ((value.exports || value.init) && !value.exportsFn) { + value.exportsFn = context.makeShimExports(value); + } + shim[id] = value; + }); + config.shim = shim; + } + + //Adjust packages if necessary. + if (cfg.packages) { + each(cfg.packages, function (pkgObj) { + var location, name; + + pkgObj = typeof pkgObj === 'string' ? {name: pkgObj} : pkgObj; + + name = pkgObj.name; + location = pkgObj.location; + if (location) { + config.paths[name] = pkgObj.location; + } + + //Save pointer to main module ID for pkg name. + //Remove leading dot in main, so main paths are normalized, + //and remove any trailing .js, since different package + //envs have different conventions: some use a module name, + //some use a file name. + config.pkgs[name] = pkgObj.name + '/' + (pkgObj.main || 'main') + .replace(currDirRegExp, '') + .replace(jsSuffixRegExp, ''); + }); + } + + //If there are any "waiting to execute" modules in the registry, + //update the maps for them, since their info, like URLs to load, + //may have changed. + eachProp(registry, function (mod, id) { + //If module already has init called, since it is too + //late to modify them, and ignore unnormalized ones + //since they are transient. + if (!mod.inited && !mod.map.unnormalized) { + mod.map = makeModuleMap(id, null, true); + } + }); + + //If a deps array or a config callback is specified, then call + //require with those args. This is useful when require is defined as a + //config object before require.js is loaded. + if (cfg.deps || cfg.callback) { + context.require(cfg.deps || [], cfg.callback); + } + }, + + makeShimExports: function (value) { + function fn() { + var ret; + if (value.init) { + ret = value.init.apply(global, arguments); + } + return ret || (value.exports && getGlobal(value.exports)); + } + return fn; + }, + + makeRequire: function (relMap, options) { + options = options || {}; + + function localRequire(deps, callback, errback) { + var id, map, requireMod; + + if (options.enableBuildCallback && callback && isFunction(callback)) { + callback.__requireJsBuild = true; + } + + if (typeof deps === 'string') { + if (isFunction(callback)) { + //Invalid call + return onError(makeError('requireargs', 'Invalid require call'), errback); + } + + //If require|exports|module are requested, get the + //value for them from the special handlers. Caveat: + //this only works while module is being defined. + if (relMap && hasProp(handlers, deps)) { + return handlers[deps](registry[relMap.id]); + } + + //Synchronous access to one module. If require.get is + //available (as in the Node adapter), prefer that. + if (req.get) { + return req.get(context, deps, relMap, localRequire); + } + + //Normalize module name, if it contains . or .. + map = makeModuleMap(deps, relMap, false, true); + id = map.id; + + if (!hasProp(defined, id)) { + return onError(makeError('notloaded', 'Module name "' + + id + + '" has not been loaded yet for context: ' + + contextName + + (relMap ? '' : '. Use require([])'))); + } + return defined[id]; + } + + //Grab defines waiting in the global queue. + intakeDefines(); + + //Mark all the dependencies as needing to be loaded. + context.nextTick(function () { + //Some defines could have been added since the + //require call, collect them. + intakeDefines(); + + requireMod = getModule(makeModuleMap(null, relMap)); + + //Store if map config should be applied to this require + //call for dependencies. + requireMod.skipMap = options.skipMap; + + requireMod.init(deps, callback, errback, { + enabled: true + }); + + checkLoaded(); + }); + + return localRequire; + } + + mixin(localRequire, { + isBrowser: isBrowser, + + /** + * Converts a module name + .extension into an URL path. + * *Requires* the use of a module name. It does not support using + * plain URLs like nameToUrl. + */ + toUrl: function (moduleNamePlusExt) { + var ext, + index = moduleNamePlusExt.lastIndexOf('.'), + segment = moduleNamePlusExt.split('/')[0], + isRelative = segment === '.' || segment === '..'; + + //Have a file extension alias, and it is not the + //dots from a relative path. + if (index !== -1 && (!isRelative || index > 1)) { + ext = moduleNamePlusExt.substring(index, moduleNamePlusExt.length); + moduleNamePlusExt = moduleNamePlusExt.substring(0, index); + } + + return context.nameToUrl(normalize(moduleNamePlusExt, + relMap && relMap.id, true), ext, true); + }, + + defined: function (id) { + return hasProp(defined, makeModuleMap(id, relMap, false, true).id); + }, + + specified: function (id) { + id = makeModuleMap(id, relMap, false, true).id; + return hasProp(defined, id) || hasProp(registry, id); + } + }); + + //Only allow undef on top level require calls + if (!relMap) { + localRequire.undef = function (id) { + //Bind any waiting define() calls to this context, + //fix for #408 + takeGlobalQueue(); + + var map = makeModuleMap(id, relMap, true), + mod = getOwn(registry, id); + + mod.undefed = true; + removeScript(id); + + delete defined[id]; + delete urlFetched[map.url]; + delete undefEvents[id]; + + //Clean queued defines too. Go backwards + //in array so that the splices do not + //mess up the iteration. + eachReverse(defQueue, function(args, i) { + if (args[0] === id) { + defQueue.splice(i, 1); + } + }); + delete context.defQueueMap[id]; + + if (mod) { + //Hold on to listeners in case the + //module will be attempted to be reloaded + //using a different config. + if (mod.events.defined) { + undefEvents[id] = mod.events; + } + + cleanRegistry(id); + } + }; + } + + return localRequire; + }, + + /** + * Called to enable a module if it is still in the registry + * awaiting enablement. A second arg, parent, the parent module, + * is passed in for context, when this method is overridden by + * the optimizer. Not shown here to keep code compact. + */ + enable: function (depMap) { + var mod = getOwn(registry, depMap.id); + if (mod) { + getModule(depMap).enable(); + } + }, + + /** + * Internal method used by environment adapters to complete a load event. + * A load event could be a script load or just a load pass from a synchronous + * load call. + * @param {String} moduleName the name of the module to potentially complete. + */ + completeLoad: function (moduleName) { + var found, args, mod, + shim = getOwn(config.shim, moduleName) || {}, + shExports = shim.exports; + + takeGlobalQueue(); + + while (defQueue.length) { + args = defQueue.shift(); + if (args[0] === null) { + args[0] = moduleName; + //If already found an anonymous module and bound it + //to this name, then this is some other anon module + //waiting for its completeLoad to fire. + if (found) { + break; + } + found = true; + } else if (args[0] === moduleName) { + //Found matching define call for this script! + found = true; + } + + callGetModule(args); + } + context.defQueueMap = {}; + + //Do this after the cycle of callGetModule in case the result + //of those calls/init calls changes the registry. + mod = getOwn(registry, moduleName); + + if (!found && !hasProp(defined, moduleName) && mod && !mod.inited) { + if (config.enforceDefine && (!shExports || !getGlobal(shExports))) { + if (hasPathFallback(moduleName)) { + return; + } else { + return onError(makeError('nodefine', + 'No define call for ' + moduleName, + null, + [moduleName])); + } + } else { + //A script that does not call define(), so just simulate + //the call for it. + callGetModule([moduleName, (shim.deps || []), shim.exportsFn]); + } + } + + checkLoaded(); + }, + + /** + * Converts a module name to a file path. Supports cases where + * moduleName may actually be just an URL. + * Note that it **does not** call normalize on the moduleName, + * it is assumed to have already been normalized. This is an + * internal API, not a public one. Use toUrl for the public API. + */ + nameToUrl: function (moduleName, ext, skipExt) { + var paths, syms, i, parentModule, url, + parentPath, bundleId, + pkgMain = getOwn(config.pkgs, moduleName); + + if (pkgMain) { + moduleName = pkgMain; + } + + bundleId = getOwn(bundlesMap, moduleName); + + if (bundleId) { + return context.nameToUrl(bundleId, ext, skipExt); + } + + //If a colon is in the URL, it indicates a protocol is used and it is just + //an URL to a file, or if it starts with a slash, contains a query arg (i.e. ?) + //or ends with .js, then assume the user meant to use an url and not a module id. + //The slash is important for protocol-less URLs as well as full paths. + if (req.jsExtRegExp.test(moduleName)) { + //Just a plain path, not module name lookup, so just return it. + //Add extension if it is included. This is a bit wonky, only non-.js things pass + //an extension, this method probably needs to be reworked. + url = moduleName + (ext || ''); + } else { + //A module that needs to be converted to a path. + paths = config.paths; + + syms = moduleName.split('/'); + //For each module name segment, see if there is a path + //registered for it. Start with most specific name + //and work up from it. + for (i = syms.length; i > 0; i -= 1) { + parentModule = syms.slice(0, i).join('/'); + + parentPath = getOwn(paths, parentModule); + if (parentPath) { + //If an array, it means there are a few choices, + //Choose the one that is desired + if (isArray(parentPath)) { + parentPath = parentPath[0]; + } + syms.splice(0, i, parentPath); + break; + } + } + + //Join the path parts together, then figure out if baseUrl is needed. + url = syms.join('/'); + url += (ext || (/^data\:|^blob\:|\?/.test(url) || skipExt ? '' : '.js')); + url = (url.charAt(0) === '/' || url.match(/^[\w\+\.\-]+:/) ? '' : config.baseUrl) + url; + } + + return config.urlArgs && !/^blob\:/.test(url) ? + url + config.urlArgs(moduleName, url) : url; + }, + + //Delegates to req.load. Broken out as a separate function to + //allow overriding in the optimizer. + load: function (id, url) { + req.load(context, id, url); + }, + + /** + * Executes a module callback function. Broken out as a separate function + * solely to allow the build system to sequence the files in the built + * layer in the right sequence. + * + * @private + */ + execCb: function (name, callback, args, exports) { + return callback.apply(exports, args); + }, + + /** + * callback for script loads, used to check status of loading. + * + * @param {Event} evt the event from the browser for the script + * that was loaded. + */ + onScriptLoad: function (evt) { + //Using currentTarget instead of target for Firefox 2.0's sake. Not + //all old browsers will be supported, but this one was easy enough + //to support and still makes sense. + if (evt.type === 'load' || + (readyRegExp.test((evt.currentTarget || evt.srcElement).readyState))) { + //Reset interactive script so a script node is not held onto for + //to long. + interactiveScript = null; + + //Pull out the name of the module and the context. + var data = getScriptData(evt); + context.completeLoad(data.id); + } + }, + + /** + * Callback for script errors. + */ + onScriptError: function (evt) { + var data = getScriptData(evt); + if (!hasPathFallback(data.id)) { + var parents = []; + eachProp(registry, function(value, key) { + if (key.indexOf('_@r') !== 0) { + each(value.depMaps, function(depMap) { + if (depMap.id === data.id) { + parents.push(key); + return true; + } + }); + } + }); + return onError(makeError('scripterror', 'Script error for "' + data.id + + (parents.length ? + '", needed by: ' + parents.join(', ') : + '"'), evt, [data.id])); + } + } + }; + + context.require = context.makeRequire(); + return context; + } + + /** + * Main entry point. + * + * If the only argument to require is a string, then the module that + * is represented by that string is fetched for the appropriate context. + * + * If the first argument is an array, then it will be treated as an array + * of dependency string names to fetch. An optional function callback can + * be specified to execute when all of those dependencies are available. + * + * Make a local req variable to help Caja compliance (it assumes things + * on a require that are not standardized), and to give a short + * name for minification/local scope use. + */ + req = requirejs = function (deps, callback, errback, optional) { + + //Find the right context, use default + var context, config, + contextName = defContextName; + + // Determine if have config object in the call. + if (!isArray(deps) && typeof deps !== 'string') { + // deps is a config object + config = deps; + if (isArray(callback)) { + // Adjust args if there are dependencies + deps = callback; + callback = errback; + errback = optional; + } else { + deps = []; + } + } + + if (config && config.context) { + contextName = config.context; + } + + context = getOwn(contexts, contextName); + if (!context) { + context = contexts[contextName] = req.s.newContext(contextName); + } + + if (config) { + context.configure(config); + } + + return context.require(deps, callback, errback); + }; + + /** + * Support require.config() to make it easier to cooperate with other + * AMD loaders on globally agreed names. + */ + req.config = function (config) { + return req(config); + }; + + /** + * Execute something after the current tick + * of the event loop. Override for other envs + * that have a better solution than setTimeout. + * @param {Function} fn function to execute later. + */ + req.nextTick = typeof setTimeout !== 'undefined' ? function (fn) { + setTimeout(fn, 4); + } : function (fn) { fn(); }; + + /** + * Export require as a global, but only if it does not already exist. + */ + if (!require) { + require = req; + } + + req.version = version; + + //Used to filter out dependencies that are already paths. + req.jsExtRegExp = /^\/|:|\?|\.js$/; + req.isBrowser = isBrowser; + s = req.s = { + contexts: contexts, + newContext: newContext + }; + + //Create default context. + req({}); + + //Exports some context-sensitive methods on global require. + each([ + 'toUrl', + 'undef', + 'defined', + 'specified' + ], function (prop) { + //Reference from contexts instead of early binding to default context, + //so that during builds, the latest instance of the default context + //with its config gets used. + req[prop] = function () { + var ctx = contexts[defContextName]; + return ctx.require[prop].apply(ctx, arguments); + }; + }); + + if (isBrowser) { + head = s.head = document.getElementsByTagName('head')[0]; + //If BASE tag is in play, using appendChild is a problem for IE6. + //When that browser dies, this can be removed. Details in this jQuery bug: + //http://dev.jquery.com/ticket/2709 + baseElement = document.getElementsByTagName('base')[0]; + if (baseElement) { + head = s.head = baseElement.parentNode; + } + } + + /** + * Any errors that require explicitly generates will be passed to this + * function. Intercept/override it if you want custom error handling. + * @param {Error} err the error object. + */ + req.onError = defaultOnError; + + /** + * Creates the node for the load command. Only used in browser envs. + */ + req.createNode = function (config, moduleName, url) { + var node = config.xhtml ? + document.createElementNS('http://www.w3.org/1999/xhtml', 'html:script') : + document.createElement('script'); + node.type = config.scriptType || 'text/javascript'; + node.charset = 'utf-8'; + node.async = true; + return node; + }; + + /** + * Does the request to load a module for the browser case. + * Make this a separate function to allow other environments + * to override it. + * + * @param {Object} context the require context to find state. + * @param {String} moduleName the name of the module. + * @param {Object} url the URL to the module. + */ + req.load = function (context, moduleName, url) { + var config = (context && context.config) || {}, + node; + if (isBrowser) { + //In the browser so use a script tag + node = req.createNode(config, moduleName, url); + + node.setAttribute('data-requirecontext', context.contextName); + node.setAttribute('data-requiremodule', moduleName); + + //Set up load listener. Test attachEvent first because IE9 has + //a subtle issue in its addEventListener and script onload firings + //that do not match the behavior of all other browsers with + //addEventListener support, which fire the onload event for a + //script right after the script execution. See: + //https://connect.microsoft.com/IE/feedback/details/648057/script-onload-event-is-not-fired-immediately-after-script-execution + //UNFORTUNATELY Opera implements attachEvent but does not follow the script + //script execution mode. + if (node.attachEvent && + //Check if node.attachEvent is artificially added by custom script or + //natively supported by browser + //read https://github.com/requirejs/requirejs/issues/187 + //if we can NOT find [native code] then it must NOT natively supported. + //in IE8, node.attachEvent does not have toString() + //Note the test for "[native code" with no closing brace, see: + //https://github.com/requirejs/requirejs/issues/273 + !(node.attachEvent.toString && node.attachEvent.toString().indexOf('[native code') < 0) && + !isOpera) { + //Probably IE. IE (at least 6-8) do not fire + //script onload right after executing the script, so + //we cannot tie the anonymous define call to a name. + //However, IE reports the script as being in 'interactive' + //readyState at the time of the define call. + useInteractive = true; + + node.attachEvent('onreadystatechange', context.onScriptLoad); + //It would be great to add an error handler here to catch + //404s in IE9+. However, onreadystatechange will fire before + //the error handler, so that does not help. If addEventListener + //is used, then IE will fire error before load, but we cannot + //use that pathway given the connect.microsoft.com issue + //mentioned above about not doing the 'script execute, + //then fire the script load event listener before execute + //next script' that other browsers do. + //Best hope: IE10 fixes the issues, + //and then destroys all installs of IE 6-9. + //node.attachEvent('onerror', context.onScriptError); + } else { + node.addEventListener('load', context.onScriptLoad, false); + node.addEventListener('error', context.onScriptError, false); + } + node.src = url; + + //Calling onNodeCreated after all properties on the node have been + //set, but before it is placed in the DOM. + if (config.onNodeCreated) { + config.onNodeCreated(node, config, moduleName, url); + } + + //For some cache cases in IE 6-8, the script executes before the end + //of the appendChild execution, so to tie an anonymous define + //call to the module name (which is stored on the node), hold on + //to a reference to this node, but clear after the DOM insertion. + currentlyAddingScript = node; + if (baseElement) { + head.insertBefore(node, baseElement); + } else { + head.appendChild(node); + } + currentlyAddingScript = null; + + return node; + } else if (isWebWorker) { + try { + //In a web worker, use importScripts. This is not a very + //efficient use of importScripts, importScripts will block until + //its script is downloaded and evaluated. However, if web workers + //are in play, the expectation is that a build has been done so + //that only one script needs to be loaded anyway. This may need + //to be reevaluated if other use cases become common. + + // Post a task to the event loop to work around a bug in WebKit + // where the worker gets garbage-collected after calling + // importScripts(): https://webkit.org/b/153317 + setTimeout(function() {}, 0); + importScripts(url); + + //Account for anonymous modules + context.completeLoad(moduleName); + } catch (e) { + context.onError(makeError('importscripts', + 'importScripts failed for ' + + moduleName + ' at ' + url, + e, + [moduleName])); + } + } + }; + + function getInteractiveScript() { + if (interactiveScript && interactiveScript.readyState === 'interactive') { + return interactiveScript; + } + + eachReverse(scripts(), function (script) { + if (script.readyState === 'interactive') { + return (interactiveScript = script); + } + }); + return interactiveScript; + } + + //Look for a data-main script attribute, which could also adjust the baseUrl. + if (isBrowser && !cfg.skipDataMain) { + //Figure out baseUrl. Get it from the script tag with require.js in it. + eachReverse(scripts(), function (script) { + //Set the 'head' where we can append children by + //using the script's parent. + if (!head) { + head = script.parentNode; + } + + //Look for a data-main attribute to set main script for the page + //to load. If it is there, the path to data main becomes the + //baseUrl, if it is not already set. + dataMain = script.getAttribute('data-main'); + if (dataMain) { + //Preserve dataMain in case it is a path (i.e. contains '?') + mainScript = dataMain; + + //Set final baseUrl if there is not already an explicit one, + //but only do so if the data-main value is not a loader plugin + //module ID. + if (!cfg.baseUrl && mainScript.indexOf('!') === -1) { + //Pull off the directory of data-main for use as the + //baseUrl. + src = mainScript.split('/'); + mainScript = src.pop(); + subPath = src.length ? src.join('/') + '/' : './'; + + cfg.baseUrl = subPath; + } + + //Strip off any trailing .js since mainScript is now + //like a module name. + mainScript = mainScript.replace(jsSuffixRegExp, ''); + + //If mainScript is still a path, fall back to dataMain + if (req.jsExtRegExp.test(mainScript)) { + mainScript = dataMain; + } + + //Put the data-main script in the files to load. + cfg.deps = cfg.deps ? cfg.deps.concat(mainScript) : [mainScript]; + + return true; + } + }); + } + + /** + * The function that handles definitions of modules. Differs from + * require() in that a string for the module should be the first argument, + * and the function to execute after dependencies are loaded should + * return a value to define the module corresponding to the first argument's + * name. + */ + define = function (name, deps, callback) { + var node, context; + + //Allow for anonymous modules + if (typeof name !== 'string') { + //Adjust args appropriately + callback = deps; + deps = name; + name = null; + } + + //This module may not have dependencies + if (!isArray(deps)) { + callback = deps; + deps = null; + } + + //If no name, and callback is a function, then figure out if it a + //CommonJS thing with dependencies. + if (!deps && isFunction(callback)) { + deps = []; + //Remove comments from the callback string, + //look for require calls, and pull them into the dependencies, + //but only if there are function args. + if (callback.length) { + callback + .toString() + .replace(commentRegExp, commentReplace) + .replace(cjsRequireRegExp, function (match, dep) { + deps.push(dep); + }); + + //May be a CommonJS thing even without require calls, but still + //could use exports, and module. Avoid doing exports and module + //work though if it just needs require. + //REQUIRES the function to expect the CommonJS variables in the + //order listed below. + deps = (callback.length === 1 ? ['require'] : ['require', 'exports', 'module']).concat(deps); + } + } + + //If in IE 6-8 and hit an anonymous define() call, do the interactive + //work. + if (useInteractive) { + node = currentlyAddingScript || getInteractiveScript(); + if (node) { + if (!name) { + name = node.getAttribute('data-requiremodule'); + } + context = contexts[node.getAttribute('data-requirecontext')]; + } + } + + //Always save off evaluating the def call until the script onload handler. + //This allows multiple modules to be in a file without prematurely + //tracing dependencies, and allows for anonymous module support, + //where the module name is not known until the script onload event + //occurs. If no context, use the global queue, and get it processed + //in the onscript load callback. + if (context) { + context.defQueue.push([name, deps, callback]); + context.defQueueMap[name] = true; + } else { + globalDefQueue.push([name, deps, callback]); + } + }; + + define.amd = { + jQuery: true + }; + + /** + * Executes the text. Normally just uses eval, but can be modified + * to use a better, environment-specific call. Only used for transpiling + * loader plugins, not for plain JS modules. + * @param {String} text the text to execute/evaluate. + */ + req.exec = function (text) { + /*jslint evil: true */ + return eval(text); + }; + + //Set up with config info. + req(cfg); +}(this, (typeof setTimeout === 'undefined' ? undefined : setTimeout))); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..87eb7248 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3669 @@ +{ + "name": "@oat-sa/tao-item-runner", + "version": "0.1.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@babel/code-frame": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.0.0.tgz", + "integrity": "sha512-OfC2uemaknXr87bdLUkWog7nYuliM9Ij5HUcajsVcMCpQrcLmtxRbVFTIqmcSkSeYRBFBRxs2FiUqFJDLdiebA==", + "dev": true, + "requires": { + "@babel/highlight": "^7.0.0" + } + }, + "@babel/highlight": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.0.0.tgz", + "integrity": "sha512-UFMC4ZeFC48Tpvj7C8UgLvtkaUuovQX+5xNWrsIoMG8o2z+XFKjKaN9iVmS84dPwVN00W4wPmqvYoZF3EGAsfw==", + "dev": true, + "requires": { + "chalk": "^2.0.0", + "esutils": "^2.0.2", + "js-tokens": "^4.0.0" + } + }, + "@csstools/postcss-sass": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-sass/-/postcss-sass-4.0.0.tgz", + "integrity": "sha512-yvm2aaODNJ8PBn43HjYiRvVJcYLEpz5BEKXxQ/7HryqcL+TnAceXZO+khadTEkjw90r8afR5wykTzvVpFeo4vw==", + "dev": true, + "requires": { + "@csstools/sass-import-resolve": "^1.0.0", + "postcss": "^7.0.14", + "sass": "^1.16.1", + "source-map": "~0.7.3" + }, + "dependencies": { + "source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", + "dev": true + } + } + }, + "@csstools/sass-import-resolve": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/sass-import-resolve/-/sass-import-resolve-1.0.0.tgz", + "integrity": "sha512-pH4KCsbtBLLe7eqUrw8brcuFO8IZlN36JjdKlOublibVdAIPHCzEnpBWOVUXK5sCf+DpBi8ZtuWtjF0srybdeA==", + "dev": true + }, + "@oat-sa/browserslist-config-tao": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@oat-sa/browserslist-config-tao/-/browserslist-config-tao-0.1.0.tgz", + "integrity": "sha512-RFC3jnyK7y2xdr98wTdxoRoXUJyjso0HA9HHbKhFaU8fOshAkvBPIPqSfyyZdiSyiuctGeoE4KjeVIo+1xMuiw==", + "dev": true + }, + "@oat-sa/tao-core-libs": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@oat-sa/tao-core-libs/-/tao-core-libs-0.1.0.tgz", + "integrity": "sha512-NwiOQTeF87vVYt6HuluQ7uAJXDwz2868O+PTivz36uDdqZT2Bh03TE9KYamldfE4eMga0ylOjreuIE5H3eY1LQ==", + "dev": true + }, + "@oat-sa/tao-core-sdk": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@oat-sa/tao-core-sdk/-/tao-core-sdk-0.3.0.tgz", + "integrity": "sha512-8wNb0GJppNZJehfhafEOSvTzuA8lPLMupmQgB9BJ0FNjrJ7kafmdx3/NlowYOIA/sgThTrpfYAE43rd1YE2Eew==", + "dev": true, + "requires": { + "idb-wrapper": "1.7.0", + "webcrypto-shim": "0.1.4" + } + }, + "@types/estree": { + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", + "dev": true + }, + "@types/node": { + "version": "12.0.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.0.8.tgz", + "integrity": "sha512-b8bbUOTwzIY3V5vDTY1fIJ+ePKDUBqt2hC2woVGotdQQhG/2Sh62HOKHrT7ab+VerXAcPyAiTEipPu/FsreUtg==", + "dev": true + }, + "@types/resolve": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-0.0.8.tgz", + "integrity": "sha512-auApPaJf3NPfe18hSoJkp8EbZzer2ISk7o8mCC3M9he/a04+gbMF97NkpD2S8riMGvm4BMRI59/SZQSaLTKpsQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "acorn": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.1.1.tgz", + "integrity": "sha512-jPTiwtOxaHNaAPg/dmrJ/beuzLRnXtB0kQPQ8JpotKJgTB6rX6c8mlf315941pyjBSaPg8NHXS9fhP4u17DpGA==", + "dev": true + }, + "acorn-jsx": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.0.1.tgz", + "integrity": "sha512-HJ7CfNHrfJLlNTzIEUTj43LNWGkqpRLxm3YjAlcD0ACydk9XynzYsCBHxut+iqt+1aBXkx9UP/w/ZqMr13XIzg==", + "dev": true + }, + "agent-base": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz", + "integrity": "sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==", + "dev": true, + "requires": { + "es6-promisify": "^5.0.0" + } + }, + "ajv": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.0.tgz", + "integrity": "sha512-nffhOpkymDECQyR0mnsUtoCE8RlX38G0rYP+wgLWFyZuUyuuojSSvi/+euOiQBIn63whYwYVIIH1TvE3tu4OEg==", + "dev": true, + "requires": { + "fast-deep-equal": "^2.0.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "amdefine": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", + "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=", + "dev": true, + "optional": true + }, + "ansi-escapes": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz", + "integrity": "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==", + "dev": true + }, + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "anymatch": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz", + "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==", + "dev": true, + "requires": { + "micromatch": "^3.1.4", + "normalize-path": "^2.1.1" + }, + "dependencies": { + "normalize-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", + "dev": true, + "requires": { + "remove-trailing-separator": "^1.0.1" + } + } + } + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=", + "dev": true + }, + "arr-flatten": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", + "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==", + "dev": true + }, + "arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=", + "dev": true + }, + "array-unique": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", + "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=", + "dev": true + }, + "assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=", + "dev": true + }, + "astral-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz", + "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", + "dev": true + }, + "async": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=", + "dev": true + }, + "async-each": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.3.tgz", + "integrity": "sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==", + "dev": true + }, + "async-limiter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.0.tgz", + "integrity": "sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==", + "dev": true + }, + "atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "dev": true + }, + "autoprefixer": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-9.6.0.tgz", + "integrity": "sha512-kuip9YilBqhirhHEGHaBTZKXL//xxGnzvsD0FtBQa6z+A69qZD6s/BAX9VzDF1i9VKDquTJDQaPLSEhOnL6FvQ==", + "dev": true, + "requires": { + "browserslist": "^4.6.1", + "caniuse-lite": "^1.0.30000971", + "chalk": "^2.4.2", + "normalize-range": "^0.1.2", + "num2fraction": "^1.2.2", + "postcss": "^7.0.16", + "postcss-value-parser": "^3.3.1" + } + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "base": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz", + "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==", + "dev": true, + "requires": { + "cache-base": "^1.0.1", + "class-utils": "^0.3.5", + "component-emitter": "^1.2.1", + "define-property": "^1.0.0", + "isobject": "^3.0.1", + "mixin-deep": "^1.2.0", + "pascalcase": "^0.1.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "binary-extensions": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz", + "integrity": "sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==", + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "dev": true, + "requires": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "browserslist": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.6.2.tgz", + "integrity": "sha512-2neU/V0giQy9h3XMPwLhEY3+Ao0uHSwHvU8Q1Ea6AgLVL1sXbX3dzPrJ8NWe5Hi4PoTkCYXOtVR9rfRLI0J/8Q==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30000974", + "electron-to-chromium": "^1.3.150", + "node-releases": "^1.1.23" + } + }, + "buffer-from": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", + "dev": true + }, + "builtin-modules": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.1.0.tgz", + "integrity": "sha512-k0KL0aWZuBt2lrxrcASWDfwOLMnodeQjodT/1SxEQAXsHANgo6ZC/VEaSEHCXt7aSTZ4/4H5LKa+tBXmW7Vtvw==", + "dev": true + }, + "cache-base": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", + "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==", + "dev": true, + "requires": { + "collection-visit": "^1.0.0", + "component-emitter": "^1.2.1", + "get-value": "^2.0.6", + "has-value": "^1.0.0", + "isobject": "^3.0.1", + "set-value": "^2.0.0", + "to-object-path": "^0.3.0", + "union-value": "^1.0.0", + "unset-value": "^1.0.0" + } + }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true + }, + "caniuse-lite": { + "version": "1.0.30000974", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30000974.tgz", + "integrity": "sha512-xc3rkNS/Zc3CmpMKuczWEdY2sZgx09BkAxfvkxlAEBTqcMHeL8QnPqhKse+5sRTi3nrw2pJwToD2WvKn1Uhvww==", + "dev": true + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "dependencies": { + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "dev": true + }, + "chokidar": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.6.tgz", + "integrity": "sha512-V2jUo67OKkc6ySiRpJrjlpJKl9kDuG+Xb8VgsGzb+aEouhgS1D0weyPU4lEzdAcsCAvrih2J2BqyXqHWvVLw5g==", + "dev": true, + "requires": { + "anymatch": "^2.0.0", + "async-each": "^1.0.1", + "braces": "^2.3.2", + "fsevents": "^1.2.7", + "glob-parent": "^3.1.0", + "inherits": "^2.0.3", + "is-binary-path": "^1.0.0", + "is-glob": "^4.0.0", + "normalize-path": "^3.0.0", + "path-is-absolute": "^1.0.0", + "readdirp": "^2.2.1", + "upath": "^1.1.1" + } + }, + "class-utils": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", + "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==", + "dev": true, + "requires": { + "arr-union": "^3.1.0", + "define-property": "^0.2.5", + "isobject": "^3.0.0", + "static-extend": "^0.1.1" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + } + } + }, + "cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=", + "dev": true, + "requires": { + "restore-cursor": "^2.0.0" + } + }, + "cli-width": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.0.tgz", + "integrity": "sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=", + "dev": true + }, + "collection-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", + "integrity": "sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA=", + "dev": true, + "requires": { + "map-visit": "^1.0.0", + "object-visit": "^1.0.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "colors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", + "integrity": "sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=", + "dev": true + }, + "commander": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.0.tgz", + "integrity": "sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ==", + "dev": true, + "optional": true + }, + "comment-parser": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-0.5.4.tgz", + "integrity": "sha512-0h7W6Y1Kb6zKQMJqdX41C5qf9ITCVIsD2qP2RaqDF3GFkXFrmuAuv5zUOuo19YzyC9scjBNpqzuaRQ2Sy5pxMQ==", + "dev": true + }, + "component-emitter": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", + "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "copy-descriptor": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", + "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=", + "dev": true + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "dev": true + }, + "corser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", + "integrity": "sha1-jtolLsqrWEDc2XXOuQ2TcMgZ/4c=", + "dev": true + }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "decode-uri-component": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", + "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", + "dev": true + }, + "deep-is": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", + "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", + "dev": true + }, + "define-property": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", + "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", + "dev": true, + "requires": { + "is-descriptor": "^1.0.2", + "isobject": "^3.0.1" + }, + "dependencies": { + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, + "ecstatic": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/ecstatic/-/ecstatic-3.3.2.tgz", + "integrity": "sha512-fLf9l1hnwrHI2xn9mEDT7KIi22UDqA2jaCwyCbSUJh9a1V+LEUSL/JO/6TIz/QyuBURWUHrFL5Kg2TtO1bkkog==", + "dev": true, + "requires": { + "he": "^1.1.1", + "mime": "^1.6.0", + "minimist": "^1.1.0", + "url-join": "^2.0.5" + } + }, + "electron-to-chromium": { + "version": "1.3.157", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.157.tgz", + "integrity": "sha512-vxGi3lOGqlupuogZxJOMfu+Q1vaOlG6XbsblWw8XnUZSr/ptbt3D6jhHT5LJPZuFUpKhbEo1u4QipivSory1Kg==", + "dev": true + }, + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, + "es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", + "dev": true + }, + "es6-promisify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", + "integrity": "sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=", + "dev": true, + "requires": { + "es6-promise": "^4.0.3" + } + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "eslint": { + "version": "5.16.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-5.16.0.tgz", + "integrity": "sha512-S3Rz11i7c8AA5JPv7xAH+dOyq/Cu/VXHiHXBPOU1k/JAM5dXqQPt3qcrhpHSorXmrpu2g0gkIBVXAqCpzfoZIg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "ajv": "^6.9.1", + "chalk": "^2.1.0", + "cross-spawn": "^6.0.5", + "debug": "^4.0.1", + "doctrine": "^3.0.0", + "eslint-scope": "^4.0.3", + "eslint-utils": "^1.3.1", + "eslint-visitor-keys": "^1.0.0", + "espree": "^5.0.1", + "esquery": "^1.0.1", + "esutils": "^2.0.2", + "file-entry-cache": "^5.0.1", + "functional-red-black-tree": "^1.0.1", + "glob": "^7.1.2", + "globals": "^11.7.0", + "ignore": "^4.0.6", + "import-fresh": "^3.0.0", + "imurmurhash": "^0.1.4", + "inquirer": "^6.2.2", + "js-yaml": "^3.13.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.3.0", + "lodash": "^4.17.11", + "minimatch": "^3.0.4", + "mkdirp": "^0.5.1", + "natural-compare": "^1.4.0", + "optionator": "^0.8.2", + "path-is-inside": "^1.0.2", + "progress": "^2.0.0", + "regexpp": "^2.0.1", + "semver": "^5.5.1", + "strip-ansi": "^4.0.0", + "strip-json-comments": "^2.0.1", + "table": "^5.2.3", + "text-table": "^0.2.0" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "lodash": { + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", + "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==", + "dev": true + } + } + }, + "eslint-plugin-es": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-es/-/eslint-plugin-es-1.4.0.tgz", + "integrity": "sha512-XfFmgFdIUDgvaRAlaXUkxrRg5JSADoRC8IkKLc/cISeR3yHVMefFHQZpcyXXEUUPHfy5DwviBcrfqlyqEwlQVw==", + "dev": true, + "requires": { + "eslint-utils": "^1.3.0", + "regexpp": "^2.0.1" + } + }, + "eslint-plugin-jsdoc": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-8.0.1.tgz", + "integrity": "sha512-kzZHxDjTgNSdDpwULMqSBZr6wKaX+vvIrg/D+UppH8rd87bh+sPzs8PEJNjsQ94tx2TSQebc3P2cXcmkIVsyeA==", + "dev": true, + "requires": { + "comment-parser": "^0.5.4", + "jsdoctypeparser": "4.0.0", + "lodash": "^4.17.11" + }, + "dependencies": { + "lodash": { + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", + "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==", + "dev": true + } + } + }, + "eslint-scope": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz", + "integrity": "sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==", + "dev": true, + "requires": { + "esrecurse": "^4.1.0", + "estraverse": "^4.1.1" + } + }, + "eslint-utils": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.3.1.tgz", + "integrity": "sha512-Z7YjnIldX+2XMcjr7ZkgEsOj/bREONV60qYeB/bjMAqqqZ4zxKyWX+BOUkdmRmA9riiIPVvo5x86m5elviOk0Q==", + "dev": true + }, + "eslint-visitor-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", + "integrity": "sha512-qzm/XxIbxm/FHyH341ZrbnMUpe+5Bocte9xkmFMzPMjRaZMcXww+MpBptFvtU+79L362nqiLhekCxCxDPaUMBQ==", + "dev": true + }, + "espree": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-5.0.1.tgz", + "integrity": "sha512-qWAZcWh4XE/RwzLJejfcofscgMc9CamR6Tn1+XRXNzrvUSSbiAjGOI/fggztjIi7y9VLPqnICMIPiGyr8JaZ0A==", + "dev": true, + "requires": { + "acorn": "^6.0.7", + "acorn-jsx": "^5.0.0", + "eslint-visitor-keys": "^1.0.0" + } + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + }, + "esquery": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.0.1.tgz", + "integrity": "sha512-SmiyZ5zIWH9VM+SRUReLS5Q8a7GxtRdxEBVZpm98rJM7Sb+A9DVCndXfkeFUd3byderg+EbDkfnevfCwynWaNA==", + "dev": true, + "requires": { + "estraverse": "^4.0.0" + } + }, + "esrecurse": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.2.1.tgz", + "integrity": "sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ==", + "dev": true, + "requires": { + "estraverse": "^4.1.0" + } + }, + "estraverse": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.2.0.tgz", + "integrity": "sha1-De4/7TH81GlhjOc0IJn8GvoL2xM=", + "dev": true + }, + "estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true + }, + "esutils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", + "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=", + "dev": true + }, + "eventemitter3": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==", + "dev": true + }, + "expand-brackets": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", + "integrity": "sha1-t3c14xXOMPa27/D4OwQVGiJEliI=", + "dev": true, + "requires": { + "debug": "^2.3.3", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "posix-character-classes": "^0.1.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=", + "dev": true, + "requires": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "external-editor": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.0.3.tgz", + "integrity": "sha512-bn71H9+qWoOQKyZDo25mOMVpSmXROAsTJVVVYzrrtol3d4y+AsKjf4Iwl2Q+IuT0kFSQ1qo166UuIwqYq7mGnA==", + "dev": true, + "requires": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + } + }, + "extglob": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", + "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", + "dev": true, + "requires": { + "array-unique": "^0.3.2", + "define-property": "^1.0.0", + "expand-brackets": "^2.1.4", + "extend-shallow": "^2.0.1", + "fragment-cache": "^0.2.1", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "extract-zip": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.6.7.tgz", + "integrity": "sha1-qEC0uK9kAyZMjbV/Txp0Mz74H+k=", + "dev": true, + "requires": { + "concat-stream": "1.6.2", + "debug": "2.6.9", + "mkdirp": "0.5.1", + "yauzl": "2.4.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=", + "dev": true + }, + "fast-json-stable-stringify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", + "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=", + "dev": true + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true + }, + "fd-slicer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.0.1.tgz", + "integrity": "sha1-i1vL2ewyfFBBv5qwI/1nUPEXfmU=", + "dev": true, + "requires": { + "pend": "~1.2.0" + } + }, + "figures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", + "integrity": "sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5" + } + }, + "file-entry-cache": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-5.0.1.tgz", + "integrity": "sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g==", + "dev": true, + "requires": { + "flat-cache": "^2.0.1" + } + }, + "fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "flat-cache": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-2.0.1.tgz", + "integrity": "sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA==", + "dev": true, + "requires": { + "flatted": "^2.0.0", + "rimraf": "2.6.3", + "write": "1.0.3" + } + }, + "flatted": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-2.0.0.tgz", + "integrity": "sha512-R+H8IZclI8AAkSBRQJLVOsxwAoHd6WC40b4QTNWIjzAa6BXOBfQcM587MXDTVPeYaopFNWHUFLx7eNmHDSxMWg==", + "dev": true + }, + "follow-redirects": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.7.0.tgz", + "integrity": "sha512-m/pZQy4Gj287eNy94nivy5wchN3Kp+Q5WgUPNy5lJSZ3sgkVKSYV/ZChMAQVIgx1SqfZ2zBZtPA2YlXIWxxJOQ==", + "dev": true, + "requires": { + "debug": "^3.2.6" + } + }, + "for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=", + "dev": true + }, + "fragment-cache": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", + "integrity": "sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk=", + "dev": true, + "requires": { + "map-cache": "^0.2.2" + } + }, + "fs-extra": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.0.1.tgz", + "integrity": "sha512-W+XLrggcDzlle47X/XnS7FXrXu9sDo+Ze9zpndeBxdgv88FHLm1HtmkhEwavruS6koanBjp098rUpHs65EmG7A==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "fsevents": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.9.tgz", + "integrity": "sha512-oeyj2H3EjjonWcFjD5NvZNE9Rqe4UW+nQBU2HNeKw0koVLEFIhtyETyAakeAM3de7Z/SW5kcA+fZUait9EApnw==", + "dev": true, + "optional": true, + "requires": { + "nan": "^2.12.1", + "node-pre-gyp": "^0.12.0" + }, + "dependencies": { + "abbrev": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "ansi-regex": { + "version": "2.1.1", + "bundled": true, + "dev": true + }, + "aproba": { + "version": "1.2.0", + "bundled": true, + "dev": true, + "optional": true + }, + "are-we-there-yet": { + "version": "1.1.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, + "balanced-match": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "bundled": true, + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "chownr": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "code-point-at": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "bundled": true, + "dev": true + }, + "console-control-strings": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "core-util-is": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "debug": { + "version": "4.1.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ms": "^2.1.1" + } + }, + "deep-extend": { + "version": "0.6.0", + "bundled": true, + "dev": true, + "optional": true + }, + "delegates": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "detect-libc": { + "version": "1.0.3", + "bundled": true, + "dev": true, + "optional": true + }, + "fs-minipass": { + "version": "1.2.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minipass": "^2.2.1" + } + }, + "fs.realpath": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "gauge": { + "version": "2.7.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + } + }, + "glob": { + "version": "7.1.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "has-unicode": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "iconv-lite": { + "version": "0.4.24", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "ignore-walk": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minimatch": "^3.0.4" + } + }, + "inflight": { + "version": "1.0.6", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "bundled": true, + "dev": true + }, + "ini": { + "version": "1.3.5", + "bundled": true, + "dev": true, + "optional": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "isarray": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "minimatch": { + "version": "3.0.4", + "bundled": true, + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "bundled": true, + "dev": true + }, + "minipass": { + "version": "2.3.5", + "bundled": true, + "dev": true, + "requires": { + "safe-buffer": "^5.1.2", + "yallist": "^3.0.0" + } + }, + "minizlib": { + "version": "1.2.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minipass": "^2.2.1" + } + }, + "mkdirp": { + "version": "0.5.1", + "bundled": true, + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, + "ms": { + "version": "2.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "needle": { + "version": "2.3.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "debug": "^4.1.0", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + } + }, + "node-pre-gyp": { + "version": "0.12.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "detect-libc": "^1.0.2", + "mkdirp": "^0.5.1", + "needle": "^2.2.1", + "nopt": "^4.0.1", + "npm-packlist": "^1.1.6", + "npmlog": "^4.0.2", + "rc": "^1.2.7", + "rimraf": "^2.6.1", + "semver": "^5.3.0", + "tar": "^4" + } + }, + "nopt": { + "version": "4.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "abbrev": "1", + "osenv": "^0.1.4" + } + }, + "npm-bundled": { + "version": "1.0.6", + "bundled": true, + "dev": true, + "optional": true + }, + "npm-packlist": { + "version": "1.4.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ignore-walk": "^3.0.1", + "npm-bundled": "^1.0.1" + } + }, + "npmlog": { + "version": "4.1.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "once": { + "version": "1.4.0", + "bundled": true, + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "os-homedir": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "os-tmpdir": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "osenv": { + "version": "0.1.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "process-nextick-args": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "rc": { + "version": "1.2.8", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "dependencies": { + "minimist": { + "version": "1.2.0", + "bundled": true, + "dev": true, + "optional": true + } + } + }, + "readable-stream": { + "version": "2.3.6", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "rimraf": { + "version": "2.6.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "glob": "^7.1.3" + } + }, + "safe-buffer": { + "version": "5.1.2", + "bundled": true, + "dev": true + }, + "safer-buffer": { + "version": "2.1.2", + "bundled": true, + "dev": true, + "optional": true + }, + "sax": { + "version": "1.2.4", + "bundled": true, + "dev": true, + "optional": true + }, + "semver": { + "version": "5.7.0", + "bundled": true, + "dev": true, + "optional": true + }, + "set-blocking": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "signal-exit": { + "version": "3.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "string-width": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "string_decoder": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "tar": { + "version": "4.4.8", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "chownr": "^1.1.1", + "fs-minipass": "^1.2.5", + "minipass": "^2.3.4", + "minizlib": "^1.1.1", + "mkdirp": "^0.5.0", + "safe-buffer": "^5.1.2", + "yallist": "^3.0.2" + } + }, + "util-deprecate": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "wide-align": { + "version": "1.1.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "string-width": "^1.0.2 || 2" + } + }, + "wrappy": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "yallist": { + "version": "3.0.3", + "bundled": true, + "dev": true + } + } + }, + "functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", + "dev": true + }, + "get-value": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=", + "dev": true + }, + "glob": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", + "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-parent": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", + "integrity": "sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=", + "dev": true, + "requires": { + "is-glob": "^3.1.0", + "path-dirname": "^1.0.0" + }, + "dependencies": { + "is-glob": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", + "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", + "dev": true, + "requires": { + "is-extglob": "^2.1.0" + } + } + } + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, + "graceful-fs": { + "version": "4.1.15", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.15.tgz", + "integrity": "sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA==", + "dev": true + }, + "handlebars": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-1.3.0.tgz", + "integrity": "sha1-npsTCpPjiUkTItl1zz7BgYw3zjQ=", + "dev": true, + "requires": { + "optimist": "~0.3", + "uglify-js": "~2.3" + }, + "dependencies": { + "optimist": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.3.7.tgz", + "integrity": "sha1-yQlBrVnkJzMokjB00s8ufLxuwNk=", + "dev": true, + "requires": { + "wordwrap": "~0.0.2" + } + } + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "has-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz", + "integrity": "sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc=", + "dev": true, + "requires": { + "get-value": "^2.0.6", + "has-values": "^1.0.0", + "isobject": "^3.0.0" + } + }, + "has-values": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz", + "integrity": "sha1-lbC2P+whRmGab+V/51Yo1aOe/k8=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "kind-of": "^4.0.0" + }, + "dependencies": { + "kind-of": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", + "integrity": "sha1-IIE989cSkosgc3hpGkUGb65y3Vc=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true + }, + "http-proxy": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.17.0.tgz", + "integrity": "sha512-Taqn+3nNvYRfJ3bGvKfBSRwy1v6eePlm3oc/aWVxZp57DQr5Eq3xhKJi7Z4hZpS8PC3H4qI+Yly5EmFacGuA/g==", + "dev": true, + "requires": { + "eventemitter3": "^3.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + } + }, + "http-server": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/http-server/-/http-server-0.11.1.tgz", + "integrity": "sha512-6JeGDGoujJLmhjiRGlt8yK8Z9Kl0vnl/dQoQZlc4oeqaUoAKQg94NILLfrY3oWzSyFaQCVNTcKE5PZ3cH8VP9w==", + "dev": true, + "requires": { + "colors": "1.0.3", + "corser": "~2.0.0", + "ecstatic": "^3.0.0", + "http-proxy": "^1.8.1", + "opener": "~1.4.0", + "optimist": "0.6.x", + "portfinder": "^1.0.13", + "union": "~0.4.3" + } + }, + "https-proxy-agent": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-2.2.1.tgz", + "integrity": "sha512-HPCTS1LW51bcyMYbxUIOO4HEOlQ1/1qRaFWcyxvwaqUS9TY88aoEuHUY33kuAh1YhVVaDQhLZsnPd+XNARWZlQ==", + "dev": true, + "requires": { + "agent-base": "^4.1.0", + "debug": "^3.1.0" + } + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "idb-wrapper": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/idb-wrapper/-/idb-wrapper-1.7.0.tgz", + "integrity": "sha1-Cn+yxw4OF3+RGOZHPGdTVrXmKD4=", + "dev": true + }, + "ignore": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", + "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", + "dev": true + }, + "import-fresh": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.0.0.tgz", + "integrity": "sha512-pOnA9tfM3Uwics+SaBLCNyZZZbK+4PTu0OPZtLlMIrv17EdBoC15S9Kn8ckJ9TZTyKb3ywNE5y1yeDxxGA7nTQ==", + "dev": true, + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + } + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", + "dev": true + }, + "inquirer": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-6.3.1.tgz", + "integrity": "sha512-MmL624rfkFt4TG9y/Jvmt8vdmOo836U7Y0Hxr2aFk3RelZEGX4Igk0KabWrcaaZaTv9uzglOqWh1Vly+FAWAXA==", + "dev": true, + "requires": { + "ansi-escapes": "^3.2.0", + "chalk": "^2.4.2", + "cli-cursor": "^2.1.0", + "cli-width": "^2.0.0", + "external-editor": "^3.0.3", + "figures": "^2.0.0", + "lodash": "^4.17.11", + "mute-stream": "0.0.7", + "run-async": "^2.2.0", + "rxjs": "^6.4.0", + "string-width": "^2.1.0", + "strip-ansi": "^5.1.0", + "through": "^2.3.6" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "lodash": { + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", + "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==", + "dev": true + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-binary-path": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz", + "integrity": "sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg=", + "dev": true, + "requires": { + "binary-extensions": "^1.0.0" + } + }, + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^0.1.6", + "is-data-descriptor": "^0.1.4", + "kind-of": "^5.0.0" + }, + "dependencies": { + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + } + } + }, + "is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=", + "dev": true + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "is-glob": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", + "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=", + "dev": true + }, + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "requires": { + "isobject": "^3.0.1" + } + }, + "is-promise": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz", + "integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=", + "dev": true + }, + "is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + }, + "jquery": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-1.9.1.tgz", + "integrity": "sha1-5M1INfqu+63lNYV2E8D8P/KtrzQ=", + "dev": true + }, + "js-reporters": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/js-reporters/-/js-reporters-1.2.1.tgz", + "integrity": "sha1-+IxgjjJKM3OpW8xFrTBeXJecRZs=", + "dev": true + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "js-yaml": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", + "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "jsdoctypeparser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsdoctypeparser/-/jsdoctypeparser-4.0.0.tgz", + "integrity": "sha512-Bh6AW8eJ1bVdofhYUuqgFOVo0FE9qII+a+Go+juEnAfaDS5lZAiIqBAFm9gDu80OqBcQ1UI3v/8cP+3D5IGVww==", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", + "dev": true + }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6" + } + }, + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "dev": true + }, + "levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + } + }, + "lodash": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-2.4.1.tgz", + "integrity": "sha1-W3cjA03aTSYuWkb7LFjXzCL3FCA=", + "dev": true + }, + "map-cache": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", + "integrity": "sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=", + "dev": true + }, + "map-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz", + "integrity": "sha1-7Nyo8TFE5mDxtb1B8S80edmN+48=", + "dev": true, + "requires": { + "object-visit": "^1.0.0" + } + }, + "micromatch": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", + "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + } + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true + }, + "mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "dev": true + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "dev": true + }, + "mixin-deep": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.1.tgz", + "integrity": "sha512-8ZItLHeEgaqEvd5lYBXfm4EZSFCX29Jb9K+lAHhDKzReKBQKj3R+7NOF6tjqYi9t4oI8VUfaWITJQm86wnXGNQ==", + "dev": true, + "requires": { + "for-in": "^1.0.2", + "is-extendable": "^1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, + "requires": { + "minimist": "0.0.8" + }, + "dependencies": { + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true + } + } + }, + "moment": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.11.1.tgz", + "integrity": "sha1-v0AmQTZA0bgCRnzzU2B/hGTWr0c=", + "dev": true + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "mute-stream": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz", + "integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=", + "dev": true + }, + "nan": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz", + "integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==", + "dev": true, + "optional": true + }, + "nanomatch": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", + "integrity": "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "fragment-cache": "^0.2.1", + "is-windows": "^1.0.2", + "kind-of": "^6.0.2", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + } + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", + "dev": true + }, + "neo-async": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.1.tgz", + "integrity": "sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw==", + "dev": true + }, + "nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "node-qunit-puppeteer": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/node-qunit-puppeteer/-/node-qunit-puppeteer-1.0.12.tgz", + "integrity": "sha512-3nIXzI11MgTvuY772J6m0XRxkgFj1/dzJ8UUuE+S8AiUhOfz/gzs/IPjmjZaA4Fgm3cRuQZViuudQXZoiNDgvQ==", + "dev": true, + "requires": { + "colors": "^1.3.2", + "puppeteer": "^1.9.0" + }, + "dependencies": { + "colors": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.3.3.tgz", + "integrity": "sha512-mmGt/1pZqYRjMxB1axhTo16/snVZ5krrKkcmMeVKxzECMMXoCgnvTPp10QgHfcbQZw8Dq2jMNG6je4JlWU0gWg==", + "dev": true + } + } + }, + "node-releases": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.23.tgz", + "integrity": "sha512-uq1iL79YjfYC0WXoHbC/z28q/9pOl8kSHaXdWmAAc8No+bDwqkZbzIJz55g/MUsPgSGm9LZ7QSUbzTcH5tz47w==", + "dev": true, + "requires": { + "semver": "^5.3.0" + } + }, + "node-watch": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/node-watch/-/node-watch-0.6.0.tgz", + "integrity": "sha512-XAgTL05z75ptd7JSVejH1a2Dm1zmXYhuDr9l230Qk6Z7/7GPcnAs/UyJJ4ggsXSvWil8iOzwQLW0zuGUvHpG8g==", + "dev": true + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + }, + "normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=", + "dev": true + }, + "num2fraction": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/num2fraction/-/num2fraction-1.2.2.tgz", + "integrity": "sha1-b2gragJ6Tp3fpFZM0lidHU5mnt4=", + "dev": true + }, + "object-copy": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz", + "integrity": "sha1-fn2Fi3gb18mRpBupde04EnVOmYw=", + "dev": true, + "requires": { + "copy-descriptor": "^0.1.0", + "define-property": "^0.2.5", + "kind-of": "^3.0.3" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "object-visit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", + "integrity": "sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=", + "dev": true, + "requires": { + "isobject": "^3.0.0" + } + }, + "object.pick": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", + "integrity": "sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=", + "dev": true, + "requires": { + "isobject": "^3.0.1" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", + "dev": true, + "requires": { + "mimic-fn": "^1.0.0" + } + }, + "opener": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.4.3.tgz", + "integrity": "sha1-XG2ixdflgx6P+jlklQ+NZnSskLg=", + "dev": true + }, + "optimist": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", + "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", + "dev": true, + "requires": { + "minimist": "~0.0.1", + "wordwrap": "~0.0.2" + }, + "dependencies": { + "minimist": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", + "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=", + "dev": true + } + } + }, + "optionator": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.2.tgz", + "integrity": "sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q=", + "dev": true, + "requires": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.4", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "wordwrap": "~1.0.0" + }, + "dependencies": { + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=", + "dev": true + } + } + }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", + "dev": true + }, + "parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "requires": { + "callsites": "^3.0.0" + } + }, + "pascalcase": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", + "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=", + "dev": true + }, + "path-dirname": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz", + "integrity": "sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=", + "dev": true + }, + "path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", + "dev": true + }, + "path-parse": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", + "dev": true + }, + "pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=", + "dev": true + }, + "portfinder": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.20.tgz", + "integrity": "sha512-Yxe4mTyDzTd59PZJY4ojZR8F+E5e97iq2ZOHPz3HDgSvYC5siNad2tLooQ5y5QHyQhc3xVqvyk/eNA3wuoa7Sw==", + "dev": true, + "requires": { + "async": "^1.5.2", + "debug": "^2.2.0", + "mkdirp": "0.5.x" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "posix-character-classes": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", + "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=", + "dev": true + }, + "postcss": { + "version": "7.0.17", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.17.tgz", + "integrity": "sha512-546ZowA+KZ3OasvQZHsbuEpysvwTZNGJv9EfyCQdsIDltPSWHAeTQ5fQy/Npi2ZDtLI3zs7Ps/p6wThErhm9fQ==", + "dev": true, + "requires": { + "chalk": "^2.4.2", + "source-map": "^0.6.1", + "supports-color": "^6.1.0" + } + }, + "postcss-scss": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-2.0.0.tgz", + "integrity": "sha512-um9zdGKaDZirMm+kZFKKVsnKPF7zF7qBAtIfTSnZXD1jZ0JNZIxdB6TxQOjCnlSzLRInVl2v3YdBh/M881C4ug==", + "dev": true, + "requires": { + "postcss": "^7.0.0" + } + }, + "postcss-value-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", + "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", + "dev": true + }, + "prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", + "dev": true + }, + "process-nextick-args": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", + "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==", + "dev": true + }, + "progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true + }, + "promise-limit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==", + "dev": true + }, + "proxy-from-env": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", + "integrity": "sha1-M8UDmPcOp+uW0h97gXYwpVeRx+4=", + "dev": true + }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true + }, + "puppeteer": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-1.17.0.tgz", + "integrity": "sha512-3EXZSximCzxuVKpIHtyec8Wm2dWZn1fc5tQi34qWfiUgubEVYHjUvr0GOJojqf3mifI6oyKnCdrGxaOI+lWReA==", + "dev": true, + "requires": { + "debug": "^4.1.0", + "extract-zip": "^1.6.6", + "https-proxy-agent": "^2.2.1", + "mime": "^2.0.3", + "progress": "^2.0.1", + "proxy-from-env": "^1.0.0", + "rimraf": "^2.6.1", + "ws": "^6.1.0" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "mime": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.4.4.tgz", + "integrity": "sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA==", + "dev": true + } + } + }, + "qs": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-2.3.3.tgz", + "integrity": "sha1-6eha2+ddoLvkyOBHaghikPhjtAQ=", + "dev": true + }, + "qunit": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/qunit/-/qunit-2.9.2.tgz", + "integrity": "sha512-wTOYHnioWHcx5wa85Wl15IE7D6zTZe2CQlsodS14yj7s2FZ3MviRnQluspBZsueIDEO7doiuzKlv05yfky1R7w==", + "dev": true, + "requires": { + "commander": "2.12.2", + "js-reporters": "1.2.1", + "minimatch": "3.0.4", + "node-watch": "0.6.0", + "resolve": "1.9.0" + }, + "dependencies": { + "commander": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.12.2.tgz", + "integrity": "sha512-BFnaq5ZOGcDN7FlrtBT4xxkgIToalIIxwjxLWVJ8bGTpe1LroqMiqQXdA7ygc7CRvaYS+9zfPGFnJqFSayx+AA==", + "dev": true + }, + "resolve": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.9.0.tgz", + "integrity": "sha512-TZNye00tI67lwYvzxCxHGjwTNlUV70io54/Ed4j6PscB8xVfuBJpRenI/o6dVk0cY0PYTY27AgCoGGxRnYuItQ==", + "dev": true, + "requires": { + "path-parse": "^1.0.6" + } + } + } + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "readdirp": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.2.1.tgz", + "integrity": "sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.11", + "micromatch": "^3.1.10", + "readable-stream": "^2.0.2" + } + }, + "regex-not": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", + "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==", + "dev": true, + "requires": { + "extend-shallow": "^3.0.2", + "safe-regex": "^1.1.0" + } + }, + "regexpp": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-2.0.1.tgz", + "integrity": "sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw==", + "dev": true + }, + "remove-trailing-separator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", + "integrity": "sha1-wkvOKig62tW8P1jg1IJJuSN52O8=", + "dev": true + }, + "repeat-element": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.3.tgz", + "integrity": "sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g==", + "dev": true + }, + "repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=", + "dev": true + }, + "require-css": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/require-css/-/require-css-0.1.10.tgz", + "integrity": "sha1-8duMbPsq0qOnQJFmzGz5mw0/RQI=", + "dev": true + }, + "requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", + "dev": true + }, + "resolve": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.11.0.tgz", + "integrity": "sha512-WL2pBDjqT6pGUNSUzMw00o4T7If+z4H2x3Gz893WoUQ5KW8Vr9txp00ykiP16VBaZF5+j/OcXJHZ9+PCvdiDKw==", + "dev": true, + "requires": { + "path-parse": "^1.0.6" + } + }, + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true + }, + "resolve-url": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", + "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=", + "dev": true + }, + "restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=", + "dev": true, + "requires": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + } + }, + "ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "dev": true + }, + "rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "rollup": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-1.15.1.tgz", + "integrity": "sha512-JErZxFKs0w7wpHZXWonAlom1Jezo0gJ7mf7JHTjOAjFGKAqNMEnlzEjMYhy6cqHgSfSPj/idVscuW+Lo6y6AoQ==", + "dev": true, + "requires": { + "@types/estree": "0.0.39", + "@types/node": "^12.0.7", + "acorn": "^6.1.1" + } + }, + "rollup-plugin-alias": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/rollup-plugin-alias/-/rollup-plugin-alias-1.5.2.tgz", + "integrity": "sha512-ODeZXhTxpD48sfcYLAFc1BGrsXKDj7o1CSNH3uYbdK3o0NxyMmaQPTNgW+ko+am92DLC8QSTe4kyxTuEkI5S5w==", + "dev": true, + "requires": { + "slash": "^3.0.0" + } + }, + "rollup-plugin-handlebars-plus": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/rollup-plugin-handlebars-plus/-/rollup-plugin-handlebars-plus-0.2.4.tgz", + "integrity": "sha512-GmoVth47tZXQmKFij8Wt/wrMb79lxdZ8B+UnqTYjMC1dlmvu6WJ0oznfYZK7aOVQDclwd5K46GxFHjFhKGuR3Q==", + "dev": true, + "requires": { + "handlebars": "^4.0.5" + }, + "dependencies": { + "handlebars": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.1.2.tgz", + "integrity": "sha512-nvfrjqvt9xQ8Z/w0ijewdD/vvWDTOweBUm96NTr66Wfvo1mJenBLwcYmPs3TIBP5ruzYGD7Hx/DaM9RmhroGPw==", + "dev": true, + "requires": { + "neo-async": "^2.6.0", + "optimist": "^0.6.1", + "source-map": "^0.6.1", + "uglify-js": "^3.1.4" + } + }, + "uglify-js": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.6.0.tgz", + "integrity": "sha512-W+jrUHJr3DXKhrsS7NUVxn3zqMOFn0hL/Ei6v0anCIMoKC93TjcflTagwIHLW7SfMFfiQuktQyFVCFHGUE0+yg==", + "dev": true, + "optional": true, + "requires": { + "commander": "~2.20.0", + "source-map": "~0.6.1" + } + } + } + }, + "rollup-plugin-node-resolve": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-node-resolve/-/rollup-plugin-node-resolve-5.0.1.tgz", + "integrity": "sha512-9s3dTu44SKQZM/Pwll42GpqXgT+WdvO0Ga01lF8cwZqJGqRUATtD+GrP3uIzZdpnbPonEJbVasfFt80VGPQqKw==", + "dev": true, + "requires": { + "@types/resolve": "0.0.8", + "builtin-modules": "^3.1.0", + "is-module": "^1.0.0", + "resolve": "^1.11.0", + "rollup-pluginutils": "^2.8.0" + } + }, + "rollup-pluginutils": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.1.tgz", + "integrity": "sha512-J5oAoysWar6GuZo0s+3bZ6sVZAC0pfqKz68De7ZgDi5z63jOVZn1uJL/+z1jeKHNbGII8kAyHF5q8LnxSX5lQg==", + "dev": true, + "requires": { + "estree-walker": "^0.6.1" + } + }, + "run-async": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.3.0.tgz", + "integrity": "sha1-A3GrSuC91yDUFm19/aZP96RFpsA=", + "dev": true, + "requires": { + "is-promise": "^2.1.0" + } + }, + "rxjs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.2.tgz", + "integrity": "sha512-HUb7j3kvb7p7eCUHE3FqjoDsC1xfZQ4AHFWfTKSpZ+sAhhz5X1WX0ZuUqWbzB2QhSLp3DoLUG+hMdEDKqWo2Zg==", + "dev": true, + "requires": { + "tslib": "^1.9.0" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "safe-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", + "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", + "dev": true, + "requires": { + "ret": "~0.1.10" + } + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "sass": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.21.0.tgz", + "integrity": "sha512-67hIIOZZtarbhI2aSgKBPDUgn+VqetduKoD+ZSYeIWg+ksNioTzeX+R2gUdebDoolvKNsQ/GY9NDxctbXluTNA==", + "dev": true, + "requires": { + "chokidar": "^2.0.0" + } + }, + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + }, + "set-value": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.0.tgz", + "integrity": "sha512-hw0yxk9GT/Hr5yJEYnHNKYXkIA8mVJgd9ditYZCe16ZczcaELYYcfvaXesNACk2O8O0nTiPQcQhGUQj8JLzeeg==", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", + "dev": true, + "requires": { + "shebang-regex": "^1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", + "dev": true + }, + "signal-exit": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", + "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", + "dev": true + }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + }, + "slice-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz", + "integrity": "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.0", + "astral-regex": "^1.0.0", + "is-fullwidth-code-point": "^2.0.0" + } + }, + "snapdragon": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", + "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==", + "dev": true, + "requires": { + "base": "^0.11.1", + "debug": "^2.2.0", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "map-cache": "^0.2.2", + "source-map": "^0.5.6", + "source-map-resolve": "^0.5.0", + "use": "^3.1.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "snapdragon-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz", + "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==", + "dev": true, + "requires": { + "define-property": "^1.0.0", + "isobject": "^3.0.0", + "snapdragon-util": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "snapdragon-util": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz", + "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==", + "dev": true, + "requires": { + "kind-of": "^3.2.0" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "source-map-resolve": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.2.tgz", + "integrity": "sha512-MjqsvNwyz1s0k81Goz/9vRBe9SZdB09Bdw+/zYyO+3CuPk6fouTaxscHkgtE8jKvf01kVfl8riHzERQ/kefaSA==", + "dev": true, + "requires": { + "atob": "^2.1.1", + "decode-uri-component": "^0.2.0", + "resolve-url": "^0.2.1", + "source-map-url": "^0.4.0", + "urix": "^0.1.0" + } + }, + "source-map-url": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.0.tgz", + "integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=", + "dev": true + }, + "split-string": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "dev": true, + "requires": { + "extend-shallow": "^3.0.0" + } + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + }, + "static-extend": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", + "integrity": "sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=", + "dev": true, + "requires": { + "define-property": "^0.2.5", + "object-copy": "^0.1.0" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + } + } + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", + "dev": true + }, + "supports-color": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz", + "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "table": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/table/-/table-5.4.0.tgz", + "integrity": "sha512-nHFDrxmbrkU7JAFKqKbDJXfzrX2UBsWmrieXFTGxiI5e4ncg3VqsZeI4EzNmX0ncp4XNGVeoxIWJXfCIXwrsvw==", + "dev": true, + "requires": { + "ajv": "^6.9.1", + "lodash": "^4.17.11", + "slice-ansi": "^2.1.0", + "string-width": "^3.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "lodash": { + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", + "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", + "dev": true + }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", + "dev": true + }, + "tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "requires": { + "os-tmpdir": "~1.0.2" + } + }, + "to-object-path": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", + "integrity": "sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "to-regex": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz", + "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==", + "dev": true, + "requires": { + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "regex-not": "^1.0.2", + "safe-regex": "^1.1.0" + } + }, + "to-regex-range": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", + "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "repeat-string": "^1.6.1" + } + }, + "tslib": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz", + "integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==", + "dev": true + }, + "type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2" + } + }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", + "dev": true + }, + "uglify-js": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.3.6.tgz", + "integrity": "sha1-+gmEdwtCi3qbKoBY9GNV0U/vIRo=", + "dev": true, + "optional": true, + "requires": { + "async": "~0.2.6", + "optimist": "~0.3.5", + "source-map": "~0.1.7" + }, + "dependencies": { + "async": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", + "integrity": "sha1-trvgsGdLnXGXCMo43owjfLUmw9E=", + "dev": true, + "optional": true + }, + "optimist": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.3.7.tgz", + "integrity": "sha1-yQlBrVnkJzMokjB00s8ufLxuwNk=", + "dev": true, + "optional": true, + "requires": { + "wordwrap": "~0.0.2" + } + }, + "source-map": { + "version": "0.1.43", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.43.tgz", + "integrity": "sha1-wkvBRspRfBRx9drL4lcbK3+eM0Y=", + "dev": true, + "optional": true, + "requires": { + "amdefine": ">=0.0.4" + } + } + } + }, + "union": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/union/-/union-0.4.6.tgz", + "integrity": "sha1-GY+9rrolTniLDvy2MLwR8kopWeA=", + "dev": true, + "requires": { + "qs": "~2.3.3" + } + }, + "union-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.0.tgz", + "integrity": "sha1-XHHDTLW61dzr4+oM0IIHulqhrqQ=", + "dev": true, + "requires": { + "arr-union": "^3.1.0", + "get-value": "^2.0.6", + "is-extendable": "^0.1.1", + "set-value": "^0.4.3" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "set-value": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-0.4.3.tgz", + "integrity": "sha1-fbCPnT0i3H945Trzw79GZuzfzPE=", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.1", + "to-object-path": "^0.3.0" + } + } + } + }, + "universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true + }, + "unset-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", + "integrity": "sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=", + "dev": true, + "requires": { + "has-value": "^0.3.1", + "isobject": "^3.0.0" + }, + "dependencies": { + "has-value": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz", + "integrity": "sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8=", + "dev": true, + "requires": { + "get-value": "^2.0.3", + "has-values": "^0.1.4", + "isobject": "^2.0.0" + }, + "dependencies": { + "isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", + "dev": true, + "requires": { + "isarray": "1.0.0" + } + } + } + }, + "has-values": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz", + "integrity": "sha1-bWHeldkd/Km5oCCJrThL/49it3E=", + "dev": true + } + } + }, + "upath": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.1.2.tgz", + "integrity": "sha512-kXpym8nmDmlCBr7nKdIx8P2jNBa+pBpIUFRnKJ4dr8htyYGJFokkr2ZvERRtUN+9SY+JqXouNgUPtv6JQva/2Q==", + "dev": true + }, + "uri-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", + "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "urix": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", + "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=", + "dev": true + }, + "url-join": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-2.0.5.tgz", + "integrity": "sha1-WvIvGMBSoACkjXuCxenC4v7tpyg=", + "dev": true + }, + "use": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", + "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", + "dev": true + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "dev": true + }, + "webcrypto-shim": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/webcrypto-shim/-/webcrypto-shim-0.1.4.tgz", + "integrity": "sha512-I2lnL+K2oPNE9ryVHwo42oDnt8XQ9E1KKMGCmcT7OXaAKPmUeCi/G0nUgLR6M6Ztj05ZCxLMGf5bXNaSo+wURg==", + "dev": true + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "wordwrap": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", + "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=", + "dev": true + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "write": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/write/-/write-1.0.3.tgz", + "integrity": "sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig==", + "dev": true, + "requires": { + "mkdirp": "^0.5.1" + } + }, + "ws": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.1.tgz", + "integrity": "sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==", + "dev": true, + "requires": { + "async-limiter": "~1.0.0" + } + }, + "yauzl": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.4.1.tgz", + "integrity": "sha1-lSj0QtqxsihOWLQ3m7GU4i4MQAU=", + "dev": true, + "requires": { + "fd-slicer": "~1.0.1" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..e11c67df --- /dev/null +++ b/package.json @@ -0,0 +1,70 @@ +{ + "name": "@oat-sa/tao-item-runner-qti", + "version": "0.1.0", + "displayName": "TAO Item Runner QTI", + "files": [ + "dist", + "src" + ], + "directories": { + "test": "test" + }, + "scripts": { + "test:keepAlive": "node ./build/webserver.js", + "test": "node ./build/testrunner.js", + "build": "rollup --config ./build/rollup.config.js", + "buildScss": "node ./build/scss.js", + "lint": "eslint src test", + "prepare": "npm run buildScss && npm run build" + }, + "repository": { + "type": "git", + "url": "https://github.com/oat-sa/tao-item-runner-qti-fe.git" + }, + "keywords": [ + "tao", + "item", + "runner", + "qti", + "fe", + "frontend" + ], + "publishConfig": { + "access": "public" + }, + "license": "GPL-2.0", + "bugs": { + "url": "https://github.com/oat-sa/tao-item-runner-qti-fe/issues" + }, + "homepage": "https://github.com/oat-sa/tao-item-runner-qti-fe#readme", + "devDependencies": { + "@csstools/postcss-sass": "^4.0.0", + "@oat-sa/browserslist-config-tao": "^0.1.0", + "@oat-sa/tao-core-libs": "^0.1.0", + "@oat-sa/tao-core-sdk": "^0.3.0", + "autoprefixer": "^9.6.0", + "eslint": "^5.16.0", + "eslint-plugin-es": "^1.4.0", + "eslint-plugin-jsdoc": "^8.0.1", + "fs-extra": "^8.0.1", + "glob": "^7.1.4", + "handlebars": "^1.3.0", + "http-server": "^0.11.1", + "jquery": "^1.9.1", + "lodash": "^2.4.1", + "moment": "^2.11.1", + "node-qunit-puppeteer": "^1.0.12", + "postcss": "^7.0.17", + "postcss-scss": "^2.0.0", + "promise-limit": "^2.7.0", + "qunit": "^2.9.2", + "require-css": "^0.1.10", + "rollup": "^1.15.1", + "rollup-plugin-alias": "^1.5.2", + "rollup-plugin-handlebars-plus": "^0.2.4", + "rollup-plugin-node-resolve": "^5.0.1" + }, + "browserslist": [ + "extends @oat-sa/browserslist-config-tao" + ] +} From 4d1e88d99c80eec272d7117709e1fac1b3deea2e Mon Sep 17 00:00:00 2001 From: Tamas Besenyei Date: Wed, 12 Jun 2019 11:33:23 +0200 Subject: [PATCH 02/45] merge code --- build/path.js | 2 +- build/rollup.config.js | 41 +- package-lock.json | 11 +- package.json | 1 + src/qtiCommonRenderer/helpers/Graphic.js | 790 ++++++++++++++++ .../helpers/PciPrettyPrint.js | 141 +++ src/qtiCommonRenderer/helpers/PciResponse.js | 145 +++ .../helpers/PortableElement.js | 47 + .../helpers/ckConfigurator.js | 50 + src/qtiCommonRenderer/helpers/container.js | 143 +++ .../helpers/instructions/Instruction.js | 114 +++ .../instructions/instructionManager.js | 243 +++++ .../helpers/itemStylesheetHandler.js | 86 ++ src/qtiCommonRenderer/helpers/patternMask.js | 79 ++ src/qtiCommonRenderer/helpers/sizeAdapter.js | 61 ++ src/qtiCommonRenderer/helpers/uploadMime.js | 137 +++ src/qtiCommonRenderer/renderers/Container.js | 24 + src/qtiCommonRenderer/renderers/Img.js | 32 + src/qtiCommonRenderer/renderers/Include.js | 23 + src/qtiCommonRenderer/renderers/Item.js | 56 ++ src/qtiCommonRenderer/renderers/Math.js | 62 ++ .../renderers/ModalFeedback.js | 65 ++ src/qtiCommonRenderer/renderers/Object.js | 43 + .../renderers/PortableInfoControl.js | 189 ++++ .../renderers/PrintedVariable.js | 28 + src/qtiCommonRenderer/renderers/Renderer.js | 25 + .../renderers/RubricBlock.js | 31 + src/qtiCommonRenderer/renderers/Stylesheet.js | 25 + src/qtiCommonRenderer/renderers/Table.js | 28 + src/qtiCommonRenderer/renderers/Tooltip.js | 42 + .../renderers/choices/Gap.js | 31 + .../renderers/choices/GapImg.js | 31 + .../renderers/choices/GapText.js | 31 + .../renderers/choices/Hottext.js | 31 + .../renderers/choices/InlineChoice.js | 36 + ...leAssociableChoice.AssociateInteraction.js | 31 + ...SimpleAssociableChoice.MatchInteraction.js | 31 + .../choices/SimpleChoice.ChoiceInteraction.js | 34 + .../choices/SimpleChoice.OrderInteraction.js | 31 + src/qtiCommonRenderer/renderers/config.js | 123 +++ .../renderers/graphic-style.json | 156 ++++ .../interactions/AssociateInteraction.js | 813 ++++++++++++++++ .../interactions/ChoiceInteraction.js | 495 ++++++++++ .../interactions/EndAttemptInteraction.js | 182 ++++ .../interactions/ExtendedTextInteraction.js | 882 ++++++++++++++++++ .../interactions/GapMatchInteraction.js | 549 +++++++++++ .../GraphicAssociateInteraction.js | 549 +++++++++++ .../GraphicGapMatchInteraction.js | 703 ++++++++++++++ .../interactions/GraphicOrderInteraction.js | 472 ++++++++++ .../interactions/HotspotInteraction.js | 273 ++++++ .../interactions/HottextInteraction.js | 205 ++++ .../interactions/InlineChoiceInteraction.js | 274 ++++++ .../interactions/MatchInteraction.js | 536 +++++++++++ .../interactions/MediaInteraction.js | 360 +++++++ .../interactions/OrderInteraction.js | 727 +++++++++++++++ .../interactions/PortableCustomInteraction.js | 203 ++++ .../renderers/interactions/Prompt.js | 31 + .../interactions/SelectPointInteraction.js | 306 ++++++ .../interactions/SliderInteraction.js | 285 ++++++ .../interactions/TextEntryInteraction.js | 291 ++++++ .../interactions/UploadInteraction.js | 388 ++++++++ .../renderers/interactions/pci/common.js | 81 ++ .../renderers/interactions/pci/ims.js | 75 ++ .../interactions/pci/instanciator.js | 47 + src/qtiCommonRenderer/tpl/choices/choice.tpl | 3 + src/qtiCommonRenderer/tpl/choices/gap.tpl | 3 + src/qtiCommonRenderer/tpl/choices/gapImg.tpl | 7 + src/qtiCommonRenderer/tpl/choices/hottext.tpl | 7 + .../tpl/choices/inlineChoice.tpl | 3 + ...impleAssociableChoice.matchInteraction.tpl | 3 + .../simpleChoice.choiceInteraction.tpl | 25 + src/qtiCommonRenderer/tpl/container.tpl | 5 + src/qtiCommonRenderer/tpl/img.tpl | 12 + src/qtiCommonRenderer/tpl/include.tpl | 3 + src/qtiCommonRenderer/tpl/infoControl.tpl | 3 + src/qtiCommonRenderer/tpl/instruction.tpl | 3 + .../associateInteraction.pair.tpl | 4 + .../tpl/interactions/associateInteraction.tpl | 16 + .../tpl/interactions/choiceInteraction.tpl | 14 + .../tpl/interactions/customInteraction.tpl | 3 + .../interactions/endAttemptInteraction.tpl | 8 + .../interactions/extendedTextInteraction.tpl | 55 ++ .../tpl/interactions/gapMatchInteraction.tpl | 8 + .../graphicAssociateInteraction.tpl | 7 + .../graphicGapMatchInteraction.tpl | 11 + .../interactions/graphicOrderInteraction.tpl | 9 + .../tpl/interactions/hotspotInteraction.tpl | 7 + .../tpl/interactions/hottextInteraction.tpl | 5 + .../interactions/inlineChoiceInteraction.tpl | 10 + .../tpl/interactions/matchInteraction.tpl | 30 + .../tpl/interactions/mediaInteraction.tpl | 5 + .../tpl/interactions/orderInteraction.tpl | 24 + .../tpl/interactions/prompt.tpl | 3 + .../interactions/selectPointInteraction.tpl | 7 + .../tpl/interactions/sliderInteraction.tpl | 4 + .../tpl/interactions/textEntryInteraction.tpl | 1 + .../tpl/interactions/uploadInteraction.tpl | 17 + src/qtiCommonRenderer/tpl/item.tpl | 4 + src/qtiCommonRenderer/tpl/math.tpl | 1 + src/qtiCommonRenderer/tpl/modalFeedback.tpl | 4 + src/qtiCommonRenderer/tpl/notification.tpl | 4 + src/qtiCommonRenderer/tpl/object.tpl | 2 + .../tpl/portableInfoControl.tpl | 3 + src/qtiCommonRenderer/tpl/printedVariable.tpl | 3 + src/qtiCommonRenderer/tpl/rubricBlock.tpl | 7 + src/qtiCommonRenderer/tpl/stylesheet.tpl | 7 + src/qtiCommonRenderer/tpl/table.tpl | 9 + src/qtiCommonRenderer/tpl/tooltip.tpl | 8 + src/qtiItem/core/Container.js | 200 ++++ src/qtiItem/core/Element.js | 466 +++++++++ src/qtiItem/core/IdentifiedElement.js | 63 ++ src/qtiItem/core/Img.js | 23 + src/qtiItem/core/Include.js | 16 + src/qtiItem/core/Item.js | 242 +++++ src/qtiItem/core/Loader.js | 436 +++++++++ src/qtiItem/core/Math.js | 138 +++ src/qtiItem/core/Object.js | 55 ++ src/qtiItem/core/PortableInfoControl.js | 122 +++ src/qtiItem/core/PrintedVariable.js | 27 + src/qtiItem/core/ResponseProcessing.js | 15 + src/qtiItem/core/RubricBlock.js | 13 + src/qtiItem/core/Stylesheet.js | 18 + src/qtiItem/core/Table.js | 41 + src/qtiItem/core/Tooltip.js | 68 ++ src/qtiItem/core/choices/AssociableHotspot.js | 5 + src/qtiItem/core/choices/Choice.js | 24 + src/qtiItem/core/choices/ContainerChoice.js | 15 + src/qtiItem/core/choices/Gap.js | 5 + src/qtiItem/core/choices/GapImg.js | 14 + src/qtiItem/core/choices/GapText.js | 23 + src/qtiItem/core/choices/Hotspot.js | 3 + src/qtiItem/core/choices/HotspotChoice.js | 5 + src/qtiItem/core/choices/Hottext.js | 10 + src/qtiItem/core/choices/InlineChoice.js | 5 + .../core/choices/SimpleAssociableChoice.js | 7 + src/qtiItem/core/choices/SimpleChoice.js | 7 + src/qtiItem/core/choices/TextEntry.js | 5 + .../core/choices/TextVariableChoice.js | 41 + src/qtiItem/core/feedbacks/Feedback.js | 7 + src/qtiItem/core/feedbacks/FeedbackBlock.js | 3 + src/qtiItem/core/feedbacks/FeedbackInline.js | 3 + src/qtiItem/core/feedbacks/ModalFeedback.js | 13 + .../core/interactions/AssociateInteraction.js | 40 + .../core/interactions/BlockInteraction.js | 42 + .../core/interactions/ChoiceInteraction.js | 28 + .../core/interactions/ContainerInteraction.js | 5 + .../core/interactions/CustomInteraction.js | 139 +++ .../interactions/EndAttemptInteraction.js | 4 + .../interactions/ExtendedTextInteraction.js | 53 ++ .../core/interactions/GapMatchInteraction.js | 31 + .../GraphicAssociateInteraction.js | 42 + .../GraphicGapMatchInteraction.js | 132 +++ .../core/interactions/GraphicInteraction.js | 17 + .../interactions/GraphicOrderInteraction.js | 28 + .../core/interactions/HotspotInteraction.js | 28 + .../core/interactions/HottextInteraction.js | 36 + .../interactions/InlineChoiceInteraction.js | 28 + .../core/interactions/InlineInteraction.js | 25 + src/qtiItem/core/interactions/Interaction.js | 231 +++++ .../core/interactions/MatchInteraction.js | 173 ++++ .../core/interactions/MediaInteraction.js | 34 + .../core/interactions/ObjectInteraction.js | 14 + .../core/interactions/OrderInteraction.js | 28 + src/qtiItem/core/interactions/Prompt.js | 5 + .../interactions/SelectPointInteraction.js | 28 + .../core/interactions/SliderInteraction.js | 28 + .../core/interactions/TextEntryInteraction.js | 28 + .../core/interactions/UploadInteraction.js | 5 + src/qtiItem/core/qtiClasses.js | 50 + .../core/response/SimpleFeedbackRule.js | 134 +++ .../core/variables/OutcomeDeclaration.js | 4 + .../core/variables/ResponseDeclaration.js | 85 ++ .../core/variables/VariableDeclaration.js | 46 + src/qtiItem/helper/EventMgr.js | 49 + src/qtiItem/helper/Parser.js | 17 + src/qtiItem/helper/container.js | 200 ++++ src/qtiItem/helper/interactionHelper.js | 100 ++ src/qtiItem/helper/maxScore.js | 826 ++++++++++++++++ src/qtiItem/helper/modalFeedback.js | 176 ++++ src/qtiItem/helper/pci.js | 16 + src/qtiItem/helper/rendererConfig.js | 33 + src/qtiItem/helper/response.js | 56 ++ src/qtiItem/helper/simpleParser.js | 193 ++++ src/qtiItem/helper/util.js | 185 ++++ src/qtiItem/helper/xincludeLoader.js | 37 + src/qtiItem/helper/xmlNsHandler.js | 107 +++ src/qtiItem/mixin/Container.js | 37 + src/qtiItem/mixin/ContainerInline.js | 19 + src/qtiItem/mixin/ContainerItemBody.js | 19 + src/qtiItem/mixin/ContainerTable.js | 39 + src/qtiItem/mixin/CustomElement.js | 68 ++ src/qtiItem/mixin/Mixin.js | 20 + src/qtiItem/mixin/NamespacedElement.js | 66 ++ src/runner/provider/manager/picManager.js | 441 +++++++++ src/runner/provider/manager/userModules.js | 54 ++ src/runner/provider/qti.js | 274 ++++++ src/runner/qtiItemRunner.js | 33 + 197 files changed, 19204 insertions(+), 4 deletions(-) create mode 100644 src/qtiCommonRenderer/helpers/Graphic.js create mode 100644 src/qtiCommonRenderer/helpers/PciPrettyPrint.js create mode 100644 src/qtiCommonRenderer/helpers/PciResponse.js create mode 100644 src/qtiCommonRenderer/helpers/PortableElement.js create mode 100644 src/qtiCommonRenderer/helpers/ckConfigurator.js create mode 100644 src/qtiCommonRenderer/helpers/container.js create mode 100644 src/qtiCommonRenderer/helpers/instructions/Instruction.js create mode 100644 src/qtiCommonRenderer/helpers/instructions/instructionManager.js create mode 100644 src/qtiCommonRenderer/helpers/itemStylesheetHandler.js create mode 100644 src/qtiCommonRenderer/helpers/patternMask.js create mode 100644 src/qtiCommonRenderer/helpers/sizeAdapter.js create mode 100644 src/qtiCommonRenderer/helpers/uploadMime.js create mode 100644 src/qtiCommonRenderer/renderers/Container.js create mode 100644 src/qtiCommonRenderer/renderers/Img.js create mode 100644 src/qtiCommonRenderer/renderers/Include.js create mode 100644 src/qtiCommonRenderer/renderers/Item.js create mode 100644 src/qtiCommonRenderer/renderers/Math.js create mode 100644 src/qtiCommonRenderer/renderers/ModalFeedback.js create mode 100644 src/qtiCommonRenderer/renderers/Object.js create mode 100644 src/qtiCommonRenderer/renderers/PortableInfoControl.js create mode 100644 src/qtiCommonRenderer/renderers/PrintedVariable.js create mode 100644 src/qtiCommonRenderer/renderers/Renderer.js create mode 100644 src/qtiCommonRenderer/renderers/RubricBlock.js create mode 100644 src/qtiCommonRenderer/renderers/Stylesheet.js create mode 100644 src/qtiCommonRenderer/renderers/Table.js create mode 100644 src/qtiCommonRenderer/renderers/Tooltip.js create mode 100644 src/qtiCommonRenderer/renderers/choices/Gap.js create mode 100644 src/qtiCommonRenderer/renderers/choices/GapImg.js create mode 100644 src/qtiCommonRenderer/renderers/choices/GapText.js create mode 100644 src/qtiCommonRenderer/renderers/choices/Hottext.js create mode 100644 src/qtiCommonRenderer/renderers/choices/InlineChoice.js create mode 100644 src/qtiCommonRenderer/renderers/choices/SimpleAssociableChoice.AssociateInteraction.js create mode 100644 src/qtiCommonRenderer/renderers/choices/SimpleAssociableChoice.MatchInteraction.js create mode 100644 src/qtiCommonRenderer/renderers/choices/SimpleChoice.ChoiceInteraction.js create mode 100644 src/qtiCommonRenderer/renderers/choices/SimpleChoice.OrderInteraction.js create mode 100644 src/qtiCommonRenderer/renderers/config.js create mode 100644 src/qtiCommonRenderer/renderers/graphic-style.json create mode 100644 src/qtiCommonRenderer/renderers/interactions/AssociateInteraction.js create mode 100644 src/qtiCommonRenderer/renderers/interactions/ChoiceInteraction.js create mode 100644 src/qtiCommonRenderer/renderers/interactions/EndAttemptInteraction.js create mode 100644 src/qtiCommonRenderer/renderers/interactions/ExtendedTextInteraction.js create mode 100644 src/qtiCommonRenderer/renderers/interactions/GapMatchInteraction.js create mode 100644 src/qtiCommonRenderer/renderers/interactions/GraphicAssociateInteraction.js create mode 100644 src/qtiCommonRenderer/renderers/interactions/GraphicGapMatchInteraction.js create mode 100644 src/qtiCommonRenderer/renderers/interactions/GraphicOrderInteraction.js create mode 100644 src/qtiCommonRenderer/renderers/interactions/HotspotInteraction.js create mode 100644 src/qtiCommonRenderer/renderers/interactions/HottextInteraction.js create mode 100644 src/qtiCommonRenderer/renderers/interactions/InlineChoiceInteraction.js create mode 100644 src/qtiCommonRenderer/renderers/interactions/MatchInteraction.js create mode 100644 src/qtiCommonRenderer/renderers/interactions/MediaInteraction.js create mode 100644 src/qtiCommonRenderer/renderers/interactions/OrderInteraction.js create mode 100644 src/qtiCommonRenderer/renderers/interactions/PortableCustomInteraction.js create mode 100644 src/qtiCommonRenderer/renderers/interactions/Prompt.js create mode 100644 src/qtiCommonRenderer/renderers/interactions/SelectPointInteraction.js create mode 100644 src/qtiCommonRenderer/renderers/interactions/SliderInteraction.js create mode 100644 src/qtiCommonRenderer/renderers/interactions/TextEntryInteraction.js create mode 100644 src/qtiCommonRenderer/renderers/interactions/UploadInteraction.js create mode 100644 src/qtiCommonRenderer/renderers/interactions/pci/common.js create mode 100644 src/qtiCommonRenderer/renderers/interactions/pci/ims.js create mode 100644 src/qtiCommonRenderer/renderers/interactions/pci/instanciator.js create mode 100755 src/qtiCommonRenderer/tpl/choices/choice.tpl create mode 100755 src/qtiCommonRenderer/tpl/choices/gap.tpl create mode 100755 src/qtiCommonRenderer/tpl/choices/gapImg.tpl create mode 100755 src/qtiCommonRenderer/tpl/choices/hottext.tpl create mode 100755 src/qtiCommonRenderer/tpl/choices/inlineChoice.tpl create mode 100755 src/qtiCommonRenderer/tpl/choices/simpleAssociableChoice.matchInteraction.tpl create mode 100755 src/qtiCommonRenderer/tpl/choices/simpleChoice.choiceInteraction.tpl create mode 100755 src/qtiCommonRenderer/tpl/container.tpl create mode 100644 src/qtiCommonRenderer/tpl/img.tpl create mode 100755 src/qtiCommonRenderer/tpl/include.tpl create mode 100644 src/qtiCommonRenderer/tpl/infoControl.tpl create mode 100755 src/qtiCommonRenderer/tpl/instruction.tpl create mode 100755 src/qtiCommonRenderer/tpl/interactions/associateInteraction.pair.tpl create mode 100755 src/qtiCommonRenderer/tpl/interactions/associateInteraction.tpl create mode 100755 src/qtiCommonRenderer/tpl/interactions/choiceInteraction.tpl create mode 100644 src/qtiCommonRenderer/tpl/interactions/customInteraction.tpl create mode 100755 src/qtiCommonRenderer/tpl/interactions/endAttemptInteraction.tpl create mode 100755 src/qtiCommonRenderer/tpl/interactions/extendedTextInteraction.tpl create mode 100644 src/qtiCommonRenderer/tpl/interactions/gapMatchInteraction.tpl create mode 100755 src/qtiCommonRenderer/tpl/interactions/graphicAssociateInteraction.tpl create mode 100755 src/qtiCommonRenderer/tpl/interactions/graphicGapMatchInteraction.tpl create mode 100755 src/qtiCommonRenderer/tpl/interactions/graphicOrderInteraction.tpl create mode 100755 src/qtiCommonRenderer/tpl/interactions/hotspotInteraction.tpl create mode 100755 src/qtiCommonRenderer/tpl/interactions/hottextInteraction.tpl create mode 100755 src/qtiCommonRenderer/tpl/interactions/inlineChoiceInteraction.tpl create mode 100755 src/qtiCommonRenderer/tpl/interactions/matchInteraction.tpl create mode 100755 src/qtiCommonRenderer/tpl/interactions/mediaInteraction.tpl create mode 100755 src/qtiCommonRenderer/tpl/interactions/orderInteraction.tpl create mode 100755 src/qtiCommonRenderer/tpl/interactions/prompt.tpl create mode 100755 src/qtiCommonRenderer/tpl/interactions/selectPointInteraction.tpl create mode 100755 src/qtiCommonRenderer/tpl/interactions/sliderInteraction.tpl create mode 100755 src/qtiCommonRenderer/tpl/interactions/textEntryInteraction.tpl create mode 100644 src/qtiCommonRenderer/tpl/interactions/uploadInteraction.tpl create mode 100755 src/qtiCommonRenderer/tpl/item.tpl create mode 100755 src/qtiCommonRenderer/tpl/math.tpl create mode 100644 src/qtiCommonRenderer/tpl/modalFeedback.tpl create mode 100755 src/qtiCommonRenderer/tpl/notification.tpl create mode 100755 src/qtiCommonRenderer/tpl/object.tpl create mode 100644 src/qtiCommonRenderer/tpl/portableInfoControl.tpl create mode 100644 src/qtiCommonRenderer/tpl/printedVariable.tpl create mode 100644 src/qtiCommonRenderer/tpl/rubricBlock.tpl create mode 100644 src/qtiCommonRenderer/tpl/stylesheet.tpl create mode 100644 src/qtiCommonRenderer/tpl/table.tpl create mode 100644 src/qtiCommonRenderer/tpl/tooltip.tpl create mode 100644 src/qtiItem/core/Container.js create mode 100644 src/qtiItem/core/Element.js create mode 100644 src/qtiItem/core/IdentifiedElement.js create mode 100644 src/qtiItem/core/Img.js create mode 100644 src/qtiItem/core/Include.js create mode 100644 src/qtiItem/core/Item.js create mode 100644 src/qtiItem/core/Loader.js create mode 100644 src/qtiItem/core/Math.js create mode 100644 src/qtiItem/core/Object.js create mode 100644 src/qtiItem/core/PortableInfoControl.js create mode 100644 src/qtiItem/core/PrintedVariable.js create mode 100644 src/qtiItem/core/ResponseProcessing.js create mode 100644 src/qtiItem/core/RubricBlock.js create mode 100644 src/qtiItem/core/Stylesheet.js create mode 100644 src/qtiItem/core/Table.js create mode 100644 src/qtiItem/core/Tooltip.js create mode 100644 src/qtiItem/core/choices/AssociableHotspot.js create mode 100644 src/qtiItem/core/choices/Choice.js create mode 100644 src/qtiItem/core/choices/ContainerChoice.js create mode 100644 src/qtiItem/core/choices/Gap.js create mode 100644 src/qtiItem/core/choices/GapImg.js create mode 100644 src/qtiItem/core/choices/GapText.js create mode 100644 src/qtiItem/core/choices/Hotspot.js create mode 100644 src/qtiItem/core/choices/HotspotChoice.js create mode 100644 src/qtiItem/core/choices/Hottext.js create mode 100644 src/qtiItem/core/choices/InlineChoice.js create mode 100644 src/qtiItem/core/choices/SimpleAssociableChoice.js create mode 100644 src/qtiItem/core/choices/SimpleChoice.js create mode 100644 src/qtiItem/core/choices/TextEntry.js create mode 100644 src/qtiItem/core/choices/TextVariableChoice.js create mode 100644 src/qtiItem/core/feedbacks/Feedback.js create mode 100644 src/qtiItem/core/feedbacks/FeedbackBlock.js create mode 100644 src/qtiItem/core/feedbacks/FeedbackInline.js create mode 100644 src/qtiItem/core/feedbacks/ModalFeedback.js create mode 100644 src/qtiItem/core/interactions/AssociateInteraction.js create mode 100644 src/qtiItem/core/interactions/BlockInteraction.js create mode 100644 src/qtiItem/core/interactions/ChoiceInteraction.js create mode 100644 src/qtiItem/core/interactions/ContainerInteraction.js create mode 100644 src/qtiItem/core/interactions/CustomInteraction.js create mode 100644 src/qtiItem/core/interactions/EndAttemptInteraction.js create mode 100644 src/qtiItem/core/interactions/ExtendedTextInteraction.js create mode 100644 src/qtiItem/core/interactions/GapMatchInteraction.js create mode 100644 src/qtiItem/core/interactions/GraphicAssociateInteraction.js create mode 100644 src/qtiItem/core/interactions/GraphicGapMatchInteraction.js create mode 100644 src/qtiItem/core/interactions/GraphicInteraction.js create mode 100644 src/qtiItem/core/interactions/GraphicOrderInteraction.js create mode 100644 src/qtiItem/core/interactions/HotspotInteraction.js create mode 100644 src/qtiItem/core/interactions/HottextInteraction.js create mode 100644 src/qtiItem/core/interactions/InlineChoiceInteraction.js create mode 100644 src/qtiItem/core/interactions/InlineInteraction.js create mode 100644 src/qtiItem/core/interactions/Interaction.js create mode 100644 src/qtiItem/core/interactions/MatchInteraction.js create mode 100644 src/qtiItem/core/interactions/MediaInteraction.js create mode 100644 src/qtiItem/core/interactions/ObjectInteraction.js create mode 100644 src/qtiItem/core/interactions/OrderInteraction.js create mode 100644 src/qtiItem/core/interactions/Prompt.js create mode 100644 src/qtiItem/core/interactions/SelectPointInteraction.js create mode 100644 src/qtiItem/core/interactions/SliderInteraction.js create mode 100644 src/qtiItem/core/interactions/TextEntryInteraction.js create mode 100644 src/qtiItem/core/interactions/UploadInteraction.js create mode 100644 src/qtiItem/core/qtiClasses.js create mode 100644 src/qtiItem/core/response/SimpleFeedbackRule.js create mode 100644 src/qtiItem/core/variables/OutcomeDeclaration.js create mode 100644 src/qtiItem/core/variables/ResponseDeclaration.js create mode 100644 src/qtiItem/core/variables/VariableDeclaration.js create mode 100644 src/qtiItem/helper/EventMgr.js create mode 100644 src/qtiItem/helper/Parser.js create mode 100644 src/qtiItem/helper/container.js create mode 100644 src/qtiItem/helper/interactionHelper.js create mode 100644 src/qtiItem/helper/maxScore.js create mode 100644 src/qtiItem/helper/modalFeedback.js create mode 100644 src/qtiItem/helper/pci.js create mode 100644 src/qtiItem/helper/rendererConfig.js create mode 100644 src/qtiItem/helper/response.js create mode 100644 src/qtiItem/helper/simpleParser.js create mode 100644 src/qtiItem/helper/util.js create mode 100644 src/qtiItem/helper/xincludeLoader.js create mode 100644 src/qtiItem/helper/xmlNsHandler.js create mode 100644 src/qtiItem/mixin/Container.js create mode 100644 src/qtiItem/mixin/ContainerInline.js create mode 100644 src/qtiItem/mixin/ContainerItemBody.js create mode 100644 src/qtiItem/mixin/ContainerTable.js create mode 100644 src/qtiItem/mixin/CustomElement.js create mode 100644 src/qtiItem/mixin/Mixin.js create mode 100644 src/qtiItem/mixin/NamespacedElement.js create mode 100644 src/runner/provider/manager/picManager.js create mode 100644 src/runner/provider/manager/userModules.js create mode 100644 src/runner/provider/qti.js create mode 100644 src/runner/qtiItemRunner.js diff --git a/build/path.js b/build/path.js index 7acb99e7..a80c51b2 100644 --- a/build/path.js +++ b/build/path.js @@ -30,5 +30,5 @@ module.exports = { scssVendorDir: path.resolve(rootPath, 'scss'), outputDir: path.resolve(rootPath, 'dist'), testOutputDir: path.resolve(rootPath, 'test'), - aliases: { taoItems: srcDir } + aliases: { taoQtiItem: srcDir } }; diff --git a/build/rollup.config.js b/build/rollup.config.js index 763dd5cf..07e2f44c 100644 --- a/build/rollup.config.js +++ b/build/rollup.config.js @@ -23,6 +23,7 @@ import handlebarsPlugin from 'rollup-plugin-handlebars-plus'; import cssResolve from './css-resolve'; import externalAlias from './external-alias'; import resolve from 'rollup-plugin-node-resolve'; +import json from 'rollup-plugin-json'; const { srcDir, outputDir, aliases } = require('./path'); const Handlebars = require('handlebars'); @@ -61,10 +62,43 @@ export default inputs.map(input => { format: 'amd', name }, - external: ['jquery', 'lodash', ...localExternals], + external: [ + 'jquery', + 'lodash', + 'handlebars', + 'i18n', + 'module', + 'context', + + 'raphael', + 'scale.raphael', + 'lib/gamp/gamp', + 'class', + 'mathJax', + 'nouislider', + 'interact', + 'select2', + 'ckeditor', + + 'taoQtiItem/portableElementRegistry/assetManager/portableAssetStrategy', + 'taoQtiItem/portableElementRegistry/ciRegistry', + 'taoQtiItem/portableElementRegistry/icRegistry', + 'taoQtiItem/qtiRunner/core/Renderer', + 'taoQtiItem/qtiCreator/model/variables/OutcomeDeclaration', + 'taoQtiItem/portableElementRegistry/provider/sideLoadingProviderFactory', + + 'taoItems/runner/api/itemRunner', + 'taoItems/assets/manager', + 'taoItems/assets/strategies', + + 'qtiInfoControlContext', + 'qtiCustomInteractionContext', + + ...localExternals + ], plugins: [ cssResolve(), - externalAlias(['core', 'util']), + externalAlias(['core', 'util', 'ui']), alias({ resolve: ['.js', '.json', '.tpl'], ...aliases @@ -79,6 +113,9 @@ export default inputs.map(input => { module: Handlebars }, templateExtension: '.tpl' + }), + json({ + preferConst: false }) ] }; diff --git a/package-lock.json b/package-lock.json index 87eb7248..21737fef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "@oat-sa/tao-item-runner", + "name": "@oat-sa/tao-item-runner-qti", "version": "0.1.0", "lockfileVersion": 1, "requires": true, @@ -2942,6 +2942,15 @@ } } }, + "rollup-plugin-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-json/-/rollup-plugin-json-4.0.0.tgz", + "integrity": "sha512-hgb8N7Cgfw5SZAkb3jf0QXii6QX/FOkiIq2M7BAQIEydjHvTyxXHQiIzZaTFgx1GK0cRCHOCBHIyEkkLdWKxow==", + "dev": true, + "requires": { + "rollup-pluginutils": "^2.5.0" + } + }, "rollup-plugin-node-resolve": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/rollup-plugin-node-resolve/-/rollup-plugin-node-resolve-5.0.1.tgz", diff --git a/package.json b/package.json index e11c67df..e4a237ff 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "rollup": "^1.15.1", "rollup-plugin-alias": "^1.5.2", "rollup-plugin-handlebars-plus": "^0.2.4", + "rollup-plugin-json": "^4.0.0", "rollup-plugin-node-resolve": "^5.0.1" }, "browserslist": [ diff --git a/src/qtiCommonRenderer/helpers/Graphic.js b/src/qtiCommonRenderer/helpers/Graphic.js new file mode 100644 index 00000000..efe35941 --- /dev/null +++ b/src/qtiCommonRenderer/helpers/Graphic.js @@ -0,0 +1,790 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2015 (original work) Open Assessment Technologies SA ; + */ + +/** + * @author Bertrand Chevrier + */ + +import $ from 'jquery'; +import _ from 'lodash'; +import raphael from 'raphael'; +import scaleRaphael from 'scale.raphael'; +import gstyle from 'taoQtiItem/qtiCommonRenderer/renderers/graphic-style'; + +//maps the QTI shapes to Raphael shapes +var shapeMap = { + default: 'rect', + poly: 'path' +}; + +//length constraints to validate coords +var coordsValidator = { + rect: 4, + ellipse: 4, + circle: 3, + poly: 6, + default: 0 +}; + +//transform the coords from the QTI system to Raphael system +var qti2raphCoordsMapper = { + /** + * Rectangle coordinate mapper: from left-x,top-y,right-x-bottom-y to x,y,w,h + * @param {Array} coords - QTI coords + * @returns {Array} raphael coords + */ + rect: function(coords) { + return [coords[0], coords[1], coords[2] - coords[0], coords[3] - coords[1]]; + }, + + /** + * Creates the coords for a default shape (a rectangle that covers all the paper) + * @param {Raphael.Paper} paper - the paper + * @returns {Array} raphael coords + */ + default: function(paper) { + return [0, 0, paper.width, paper.height]; + }, + + /** + * polygone coordinate mapper: from x1,y1,...,xn,yn to SVG path format + * @param {Array} coords - QTI coords + * @returns {Array} path desc + */ + poly: function(coords) { + var a; + var size = coords.length; + + // autoClose if needed + if (coords[0] !== coords[size - 2] && coords[1] !== coords[size - 1]) { + coords.push(coords[0]); + coords.push(coords[1]); + } + + // move to first point + coords[0] = 'M' + coords[0]; + + for (a = 1; a < size; a++) { + if (a % 2 === 0) { + coords[a] = 'L' + coords[a]; + } + } + + return [coords.join(' ')]; + } +}; + +//transform the coords from a raphael shape to the QTI system +var raph2qtiCoordsMapper = { + /** + * Rectangle coordinate mapper: from x,y,w,h to left-x,top-y,right-x-bottom-y + * @param {Object} attr - Raphael Element's attributes + * @returns {Array} qti based coords + */ + rect: function(attr) { + return [attr.x, attr.y, attr.x + attr.width, attr.y + attr.height]; + }, + + /** + * Circle coordinate mapper + * @param {Object} attr - Raphael Element's attributes + * @returns {Array} qti based coords + */ + circle: function(attr) { + return [attr.cx, attr.cy, attr.r]; + }, + + /** + * Ellispe coordinate mapper + * @param {Object} attr - Raphael Element's attributes + * @returns {Array} qti based coords + */ + ellipse: function(attr) { + return [attr.cx, attr.cy, attr.rx, attr.ry]; + }, + + /** + * Get the coords for a default shape (a rectangle that covers all the paper) + * @param {Object} attr - Raphael Element's attributes + * @returns {Array} qti based coords + */ + default: function(attr) { + return this.rect(attr); + }, + + /** + * polygone coordinate mapper: from SVG path (available as segments) to x1,y1,...,xn,yn format + * @param {Raphael.Paper} paper - the paper + * @returns {Array} raphael coords + */ + path: function(attr) { + var poly = []; + var i; + + if (_.isArray(attr.path)) { + for (i = 1; i < attr.path.length; i++) { + if (attr.path[i].length === 3) { + poly.push(attr.path[i][1]); + poly.push(attr.path[i][2]); + } + } + } + + return poly; + } +}; + +/** + * Graphic interaction helper + * @exports qtiCommonRenderer/helpers/Graphic + */ +var GraphicHelper = { + /** + * Raw access to the styles + * @type {Object} + */ + _style: gstyle, + + /** + * Apply the style defined by name to the element + * @param {Raphael.Element} element - the element to change the state + * @param {String} state - the name of the state (from states) to switch to + */ + setStyle: function(element, name) { + if (element && gstyle[name]) { + element.attr(gstyle[name]); + } + }, + + /** + * Create a Raphael paper with a bg image, that is width responsive + * @param {String} id - the id of the DOM element that will contain the paper + * @param {String} serial - the interaction unique indentifier + * @param {Object} options - the paper parameters + * @param {String} options.img - the url of the background image + * @param {jQueryElement} [options.container] - the parent of the paper element (got the closest parent by default) + * @param {Number} [options.width] - the paper width + * @param {Number} [options.height] - the paper height + * @param {String} [options.imgId] - an identifier for the image element + * @param {Function} [options.done] - executed once the image is loaded + * @returns {Raphael.Paper} the paper + */ + responsivePaper: function(id, serial, options) { + var paper, image; + + var $container = options.container || $('#' + id).parent(); + var $editor = $('.image-editor', $container); + var $body = $container.closest('.qti-itemBody'); + var resizer = _.throttle(resizePaper, 10); + + var imgWidth = options.width || $container.innerWidth(); + var imgHeight = options.height || $container.innerHeight(); + + paper = scaleRaphael(id, imgWidth, imgHeight); + image = paper.image(options.img, 0, 0, imgWidth, imgHeight); + image.id = options.imgId || image.id; + paper.setViewBox(0, 0, imgWidth, imgHeight); + + resizer(); + + //retry to resize once the SVG is loaded + $(image.node) + .attr('externalResourcesRequired', 'true') + .on('load', resizer); + + if (raphael.type === 'SVG') { + // TODO: move listeners somewhere they can be easily turned off + $(window).on('resize.qti-widget.' + serial, resizer); + // TODO: favor window resize event and deprecate $container resive event (or don't allow $container to be destroyed and rebuilt + $container.on('resize.qti-widget.' + serial, resizer); + $(document).on('customcssloaded.styleeditor', resizer); + } else { + $container.find('.main-image-box').width(imgWidth); + if (typeof options.resize === 'function') { + options.resize(imgWidth, 1); + } + } + + /** + * scale the raphael paper + * @private + */ + function resizePaper(e, givenWidth) { + var diff, maxWidth, containerWidth, containerHeight, factor; + + if (e) { + e.stopPropagation(); + } + + diff = $editor.outerWidth() - $editor.width() + ($container.outerWidth() - $container.width()) + 1; + maxWidth = $body.width(); + containerWidth = $container.innerWidth(); + + if (containerWidth > 0 || givenWidth > 0) { + if (givenWidth < containerWidth && givenWidth < maxWidth) { + containerWidth = givenWidth - diff; + } else if (containerWidth > maxWidth) { + containerWidth = maxWidth - diff; + } else { + containerWidth -= diff; + } + + factor = containerWidth / imgWidth; + containerHeight = imgHeight * factor; + + if (containerWidth > 0) { + paper.changeSize(containerWidth, containerHeight, false, false); + } + + if (typeof options.resize === 'function') { + options.resize(containerWidth, factor); + } + + $container.trigger('resized.qti-widget'); + } + } + + return paper; + }, + + /** + * Create a new Element into a raphael paper + * @param {Raphael.Paper} paper - the interaction paper + * @param {String} type - the shape type + * @param {String|Array.} coords - qti coords as a string or an array of number + * @param {Object} [options] - additional creation options + * @param {String} [options.id] - to set the new element id + * @param {String} [options.title] - to set the new element title + * @param {String} [options.style = basic] - to default style + * @param {Boolean} [options.hover = true] - to disable the default hover state + * @param {Boolean} [options.touchEffect = true] - a circle appears on touch + * @param {Boolean} [options.qtiCoords = true] - if the coords are in QTI format + * @returns {Raphael.Element} the created element + */ + createElement: function(paper, type, coords, options) { + var self = this; + var element; + var shaper = shapeMap[type] ? paper[shapeMap[type]] : paper[type]; + var shapeCoords = options.qtiCoords !== false ? self.raphaelCoords(paper, type, coords) : coords; + + if (typeof shaper === 'function') { + element = shaper.apply(paper, shapeCoords); + if (element) { + if (options.id) { + element.id = options.id; + } + + if (options.title) { + element.attr('title', options.title); + } + + element.attr(gstyle[options.style || 'basic']).toFront(); + + //prevent issue in firefox 37 + $(element.node).removeAttr('stroke-dasharray'); + + if (options.hover !== false) { + element.hover( + function() { + if (!element.flashing) { + self.updateElementState(this, 'hover'); + } + }, + function() { + if (!element.flashing) { + self.updateElementState( + this, + this.active ? 'active' : this.selectable ? 'selectable' : 'basic' + ); + } + } + ); + } + + if (options.touchEffect !== false) { + element.touchstart(function() { + self.createTouchCircle(paper, element.getBBox()); + }); + } + } + } else { + throw new Error('Unable to find method ' + type + ' on paper'); + } + + return element; + }, + + /** + * Create target point + * @param {Raphael.Paper} paper - the paper + * @param {Object} [options] + * @param {Object} [options.id] - and id to identify the target + * @param {Object} [options.point] - the point to add to the paper + * @param {Number} [options.point.x = 0] - point's x coord + * @param {Number} [options.point.y = 0] - point's y coord + * @param {Boolean} [options.hover] = true - the target has an hover effect + * @param {Function} [options.create] - call once created + * @param {Function} [options.remove] - call once removed + */ + createTarget: function createTarget(paper, options) { + var baseSize, count, factor, half, hover, layer, point, self, tBBox, targetSize, x, y, target; + + options = options || {}; + + self = this; + point = options.point || { x: 0, y: 0 }; + factor = paper.w !== 0 ? paper.width / paper.w : 1; + hover = typeof options.hover === 'undefined' ? true : !!options.hover; + + baseSize = 18; // this is the base size of the path element to be placed on svg (i.e. the path element crosshair is created to have a size of 18) + half = baseSize / 2; + x = point.x - half; + y = point.y - half; + targetSize = factor !== 0 ? 2 / factor : 2; + + //create the target from a path + target = paper + .path(gstyle.target.path) + .transform('t' + x + ',' + y + 's' + targetSize) + .attr(gstyle.target) + .attr('title', _('Click again to remove')); + + //generate an id if not set in options + if (options.id) { + target.id = options.id; + } else { + count = 0; + paper.forEach(function(element) { + if (element.data('target')) { + count++; + } + }); + target.id = 'target-' + count; + } + + tBBox = target.getBBox(); + + //create an invisible rect over the target to ensure path selection + layer = paper + .rect(tBBox.x, tBBox.y, tBBox.width, tBBox.height) + .attr(gstyle.layer) + .click(function() { + var id = target.id; + var p = this.data('point'); + + if (_.isFunction(options.select)) { + options.select(target, p, this); + } + + if (_.isFunction(options.remove)) { + this.remove(); + target.remove(); + options.remove(id, p); + } + }); + + if (hover) { + layer.hover( + function() { + if (!target.flashing) { + self.setStyle(target, 'target-hover'); + } + }, + function() { + if (!target.flashing) { + self.setStyle(target, 'target-success'); + } + } + ); + } + + layer.id = 'layer-' + target.id; + layer.data('point', point); + target.data('target', point); + + if (_.isFunction(options.create)) { + options.create(target); + } + + return target; + }, + + /** + * Get the Raphael coordinate from QTI coordinate + * @param {Raphael.Paper} paper - the interaction paper + * @param {String} type - the shape type + * @param {String|Array.} coords - qti coords as a string or an array of number + * @returns {Array} the arguments array of coordinate to give to the approriate raphael shapre creator + */ + raphaelCoords: function raphaelCoords(paper, type, coords) { + var shapeCoords; + + if (_.isString(coords)) { + coords = _.map(coords.split(','), function(coord) { + return parseInt(coord, 10); + }); + } + + if (!_.isArray(coords) || coords.length < coordsValidator[type]) { + throw new Error('Invalid coords ' + JSON.stringify(coords) + ' for type ' + type); + } + + switch (type) { + case 'rect': + shapeCoords = qti2raphCoordsMapper.rect(coords); + break; + case 'default': + shapeCoords = qti2raphCoordsMapper['default'].call(null, paper); + break; + case 'poly': + shapeCoords = qti2raphCoordsMapper.poly(coords); + break; + default: + shapeCoords = coords; + break; + } + + return shapeCoords; + }, + + /** + * Get the QTI coordinates from a Raphael Element + * @param {Raphael.Element} element - the shape to get the coords from + * @returns {String} the QTI coords + */ + qtiCoords: function qtiCoords(element) { + var mapper = raph2qtiCoordsMapper[element.type]; + var result = ''; + + if (_.isFunction(mapper)) { + result = _.map(mapper.call(raph2qtiCoordsMapper, element.attr()), function(coord) { + return _.parseInt(coord); + }).join(','); + } + + return result; + }, + + /** + * Create a circle that animate and disapear from a shape. + * + * @param {Raphael.Paper} paper - the paper + * @param {Raphael.Element} element - used to get the bbox from + */ + createTouchCircle: function(paper, bbox) { + var radius = bbox.width > bbox.height ? bbox.width : bbox.height; + var tCircle = paper.circle(bbox.x + bbox.width / 2, bbox.y + bbox.height / 2, radius); + + tCircle.attr(gstyle['touch-circle']); + + _.defer(function() { + tCircle.animate({ r: radius + 5, opacity: 0.7 }, 300, function() { + tCircle.remove(); + }); + }); + }, + + /** + * Create a text, that scales. + * + * @param {Raphael.Paper} paper - the paper + * @param {Object} options - the text options + * @param {Number} options.left - x coord + * @param {Number} options.top - y coord + * @param {String} [options.content] - the text content + * @param {String} [options.id] - the element identifier + * @param {String} [options.style = 'small-text'] - the style name according to the graphic-style.json keys + * @param {String} [options.title] - the text tooltip content + * @param {Boolean} [options.hide = false] - if the text starts hidden + * @returns {Raphael.Element} the created text + */ + createText: function(paper, options) { + var fontSize, scaledFontSize, text; + var top = options.top || 0; + var left = options.left || 0; + var content = options.content || ''; + var style = options.style || 'small-text'; + var title = options.title || ''; + var factor = 1; + + if (paper.width && paper.w) { + factor = paper.width / paper.w; + } + + text = paper.text(left, top, content).toFront(); + if (options.id) { + text.id = options.id; + } + + if (options.hide) { + text.hide(); + } + + text.attr(gstyle[style]); + + if (typeof factor !== 'undefined' && factor !== 1) { + fontSize = parseInt(text.attr('font-size'), 10); + scaledFontSize = Math.floor(fontSize / factor) + 1; + + text.attr('font-size', scaledFontSize); + } + + if (title) { + this.updateTitle(text, title); + } + + return text; + }, + + /** + * Create a text in the middle of the related shape. + * + * @param {Raphael.Paper} paper - the paper + * @param {Raphael.Element} shape - the shape to add the text to + * @param {Object} options - the text options + * @param {String} [options.content] - the text content + * @param {String} [options.id] - the element identifier + * @param {String} [options.style = 'small-text'] - the style name according to the graphic-style.json keys + * @param {String} [options.title] - the text tooltip content + * @param {Boolean} [options.hide = false] - if the text starts hidden + * @param {Boolean} [options.shapeClick = false] - clicking the text delegates to the shape + * @returns {Raphael.Element} the created text + */ + createShapeText: function(paper, shape, options) { + var self = this; + var bbox = shape.getBBox(); + + var text = this.createText( + paper, + _.merge( + { + left: bbox.x + bbox.width / 2, + top: bbox.y + bbox.height / 2 + }, + options + ) + ); + + if (options.shapeClick) { + text.click(function() { + self.trigger(shape, 'click'); + }); + } + + return text; + }, + + /** + * Create an image with a padding and a border, using a set. + * + * @param {Raphael.Paper} paper - the paper + * @param {Object} options - image options + * @param {Number} options.left - x coord + * @param {Number} options.top - y coord + * @param {Number} options.width - image width + * @param {Number} options.height - image height + * @param {Number} options.url - image ulr + * @param {Number} [options.padding = 6] - a multiple of 2 is welcomed + * @param {Boolean} [options.border = false] - add a border around the image + * @param {Boolean} [options.shadow = false] - add a shadow back the image + * @returns {Raphael.Element} the created set, augmented of a move(x,y) method + */ + createBorderedImage: function(paper, options) { + var padding = options.padding >= 0 ? options.padding : 6; + var halfPad = padding / 2; + + var rx = options.left, + ry = options.top, + rw = options.width + padding, + rh = options.height + padding; + + var ix = options.left + halfPad, + iy = options.top + halfPad, + iw = options.width, + ih = options.height; + + var set = paper.set(); + + //create a rectangle with a padding and a border. + var rect = paper + .rect(rx, ry, rw, rh) + .attr(options.border ? gstyle['imageset-rect-stroke'] : gstyle['imageset-rect-no-stroke']); + + //and an image centered into the rectangle. + var image = paper.image(options.url, ix, iy, iw, ih).attr(gstyle['imageset-img']); + + if (options.shadow) { + set.push( + rect.glow({ + width: 2, + offsetx: 1, + offsety: 1 + }) + ); + } + + set.push(rect, image); + + /** + * Add a move method to set that keep the given coords during an animation + * @private + * @param {Number} x - destination + * @param {Number} y - destination + * @param {Number} [duration = 400] - the animation duration + * @returns {Raphael.Element} the set for chaining + */ + set.move = function move(x, y, duration) { + var animation = raphael.animation({ x: x, y: y }, duration || 400); + var elt = rect.animate(animation); + image.animateWith(elt, animation, { x: x + halfPad, y: y + halfPad }, duration || 400); + return set; + }; + + return set; + }, + + /** + * Update the visual state of an Element + * @param {Raphael.Element} element - the element to change the state + * @param {String} state - the name of the state (from states) to switch to + * @param {String} [title] - a title linked to this step + */ + updateElementState: function(element, state, title) { + if (element && element.animate) { + element.animate(gstyle[state], 200, 'linear', function() { + element.attr(gstyle[state]); //for attr that don't animate + + //preven issue in firefox 37 + $(element.node).removeAttr('stroke-dasharray'); + }); + + if (title) { + this.updateTitle(element, title); + } + } + }, + + /** + * Update the title of an element (the attr method of Raphael adds only new node instead of updating exisitings). + * @param {Raphael.Element} element - the element to update the title + * @param {String} [title] - the new title + */ + updateTitle: function(element, title) { + if (element && element.node) { + //removes all remaining titles nodes + _.forEach(element.node.children, function(child) { + if (child.nodeName.toLowerCase() === 'title') { + element.node.removeChild(child); + } + }); + + //then set the new title + element.attr('title', title); + } + }, + + /** + * Highlight an element with the error style + * @param {Raphael.Element} element - the element to hightlight + * @param {String} [restorState = 'basic'] - the state to restore the elt into after flash + */ + highlightError: function(element, restoredState) { + var self = this; + if (element) { + element.flashing = true; + self.updateElementState(element, 'error'); + _.delay(function() { + self.updateElementState(element, restoredState || 'basic'); + element.flashing = false; + }, 800); + } + }, + + /** + * Trigger an event already bound to a raphael element + * @param {Raphael.Element} element + * @param {String} event - the event name + * + */ + trigger: function(element, event) { + var evt = _.where(element.events, { name: event }); + if (evt.length && evt[0] && typeof evt[0].f === 'function') { + evt[0].f.apply(element, Array.prototype.slice.call(arguments, 2)); + } + }, + + /** + * Get an x/y point from a MouseEvent + * @param {MouseEvent} event - the source event + * @param {Raphael.Paper} paper - the interaction paper + * @param {jQueryElement} $container - the paper container + * @param {Boolean} isResponsive - if the paper is scaling + * @returns {Object} x,y point + */ + getPoint: function getPoint(event, paper, $container) { + var point = this.clickPoint($container, event); + var rect = $container.get(0).getBoundingClientRect(); + var factor = paper.w / rect.width; + + point.x = Math.round(point.x * factor); + point.y = Math.round(point.y * factor); + + return point; + }, + + /** + * Get paper position relative to the container + * @param {jQueryElement} $container - the paper container + * @param {Raphael.Paper} paper - the interaction paper + * @returns {Object} position with top and left + */ + position: function($container, paper) { + var pw = parseInt(paper.w || paper.width, 10); + var cw = parseInt($container.width(), 10); + var ph = parseInt(paper.w || paper.width, 10); + var ch = parseInt($container.height(), 10); + + return { + left: (cw - pw) / 2, + top: (ch - ph) / 2 + }; + }, + + /** + * Get a point from a click event + * @param {jQueryElement} $container - the element that contains the paper + * @param {MouseEvent} event - the event triggered by the click + * @returns {Object} the x,y point + */ + clickPoint: function($container, event) { + var x, y; + var offset = $container.offset(); + + if (event.pageX || event.pageY) { + x = event.pageX - offset.left; + y = event.pageY - offset.top; + } else if (event.clientX || event.clientY) { + x = event.clientX + document.body.scrollLeft + document.documentElement.scrollLeft - offset.left; + y = event.clientY + document.body.scrollTop + document.documentElement.scrollTop - offset.top; + } + + return { x: x, y: y }; + } +}; + +export default GraphicHelper; diff --git a/src/qtiCommonRenderer/helpers/PciPrettyPrint.js b/src/qtiCommonRenderer/helpers/PciPrettyPrint.js new file mode 100644 index 00000000..4a701f63 --- /dev/null +++ b/src/qtiCommonRenderer/helpers/PciPrettyPrint.js @@ -0,0 +1,141 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2015 (original work) Open Assessment Technologies SA ; + */ +import _ from 'lodash'; + +var _formatters = { + boolean: function(value) { + return value ? 'true' : 'false'; + }, + integer: function(value) { + return value; + }, + float: function(value) { + return value; + }, + string: function(value) { + return value === '' ? 'NULL' : '"' + value + '"'; + }, + point: function(value) { + return '[' + value[0] + ', ' + value[1] + ']'; + }, + pair: function(value) { + return '[' + value[0] + ', ' + value[1] + ']'; + }, + directedPair: function(value) { + return '[' + value[0] + ', ' + value[1] + ']'; + }, + duration: function(value) { + return value; + }, + file: function(value) { + return 'binary data'; + }, + uri: function(value) { + return value; + }, + intOrIdentifier: function(value) { + return value; + }, + identifier: function(value) { + return value; + } +}; + +/** + * Return the pretty print string for a qti base variable + * + * @param {type} value + * @param {type} withType - the qti baseType + * @returns {String} + */ +function printBase(value, withType) { + var print = '', + base = value.base; + + withType = typeof withType !== 'undefined' ? withType : true; + + if (base) { + _.forIn(_formatters, function(formatter, baseType) { + if (base[baseType] !== undefined) { + print += withType ? '(' + baseType + ') ' : ''; + print += formatter(base[baseType]); + + return false; + } + }); + + return print; + } +} + +/** + * Return the pretty print string for a qti list variable + * + * @param {object} value + * @param {string} withType - the qti basetype of the list + * @returns {string} + */ +function printList(value, withType) { + var print = '', + list = value.list; + + withType = typeof withType !== 'undefined' ? withType : true; + + if (list) { + _.forIn(_formatters, function(formatter, baseType) { + if (list[baseType] !== undefined) { + print += withType ? '(' + baseType + ') ' : ''; + + print += '['; + + _.each(list[baseType], function(value) { + print += formatter(value) + ', '; + }); + + if (_.size(list[baseType])) { + print = print.substring(0, print.length - 2); + } + + print += ']'; + + return false; + } + }); + + return print; + } +} + +/** + * Return the pretty print string for a qti record variable + * + * @param {object} value + * @returns {String} + */ +function printRecord(value) { + if (value && value.record) { + return '(record) ' + JSON.stringify(value.record); + } + return ''; +} + +export default { + printBase: printBase, + printList: printList, + printRecord: printRecord +}; diff --git a/src/qtiCommonRenderer/helpers/PciResponse.js b/src/qtiCommonRenderer/helpers/PciResponse.js new file mode 100644 index 00000000..e10e7d7f --- /dev/null +++ b/src/qtiCommonRenderer/helpers/PciResponse.js @@ -0,0 +1,145 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2015 (original work) Open Assessment Technologies SA ; + */ +import _ from 'lodash'; +import pciPrettyPrint from 'taoQtiItem/qtiCommonRenderer/helpers/PciPrettyPrint'; + +var _qtiModelPciResponseCardinalities = { + single: 'base', + multiple: 'list', + ordered: 'list', + record: 'record' +}; + +export default { + /** + * Parse a response variable formatted according to IMS PCI: http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * @see serialize + * @param {Object} response + * @param {Object} interaction + * @returns {Array} + */ + unserialize: function(response, interaction) { + var ret = [], + responseDeclaration = interaction.getResponseDeclaration(), + baseType = responseDeclaration.attr('baseType'), + cardinality = responseDeclaration.attr('cardinality'), + mappedCardinality; + + if (_qtiModelPciResponseCardinalities[cardinality]) { + mappedCardinality = _qtiModelPciResponseCardinalities[cardinality]; + var responseValues = response[mappedCardinality]; + + if (responseValues === null) { + ret = []; + } else if (_.isObject(responseValues)) { + if (responseValues[baseType] !== undefined) { + ret = responseValues[baseType]; + ret = _.isArray(ret) ? ret : [ret]; + } else { + throw 'invalid response baseType'; + } + } else { + throw 'invalid response cardinality, expected ' + cardinality + ' (' + mappedCardinality + ')'; + } + } else { + throw 'unknown cardinality in the responseDeclaration of the interaction'; + } + + return ret; + }, + /** + * Serialize the input response array into the format to be send to result server according to IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * With the only exception for empty response, which is represented by a javascript "null" value + * + * @see http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * @param {Array} responseValues + * @param {Object} interaction + * @returns {Object|null} + */ + serialize: function(responseValues, interaction) { + if (!_.isArray(responseValues)) { + throw 'invalid argument : responseValues must be an Array'; + } + + var response = {}, + responseDeclaration = interaction.getResponseDeclaration(), + baseType = responseDeclaration.attr('baseType'), + cardinality = responseDeclaration.attr('cardinality'), + mappedCardinality; + + responseValues = _.map(responseValues || [], function(v) { + return baseType === 'boolean' ? v === true || v === 'true' : v; + }); + + if (_qtiModelPciResponseCardinalities[cardinality]) { + mappedCardinality = _qtiModelPciResponseCardinalities[cardinality]; + if (mappedCardinality === 'base') { + if (responseValues.length === 0) { + //return empty response: + response.base = null; + } else { + response.base = {}; + response.base[baseType] = responseValues[0]; + } + } else { + response[mappedCardinality] = {}; + response[mappedCardinality][baseType] = responseValues; + } + } else { + throw 'unknown cardinality in the responseDeclaration of the interaction'; + } + + return response; + }, + isEmpty: function(response) { + return ( + response === null || + _.isEmpty(response) || + response.base === null || + (_.isArray(response.list) && _.isEmpty(response.list)) || + (_.isArray(response.record) && _.isEmpty(response.record)) + ); + }, + + /** + * Print a PCI JSON response into a human-readable string. + * + * @param {Object} response A response in PCI JSON representation. + * @returns {String} A human-readable version of the PCI JSON representation. + */ + prettyPrint: function(response) { + var print = ''; + + if (typeof response.base !== 'undefined') { + // -- BaseType. + print += pciPrettyPrint.printBase(response, true); + } else if (typeof response.list !== 'undefined') { + // -- ListType + print += pciPrettyPrint.printList(response, true); + } else if (typeof response.record !== 'undefined') { + // -- RecordType + print += pciPrettyPrint.printRecord(response, true); + } else { + throw 'Not a valid PCI JSON Response'; + } + + return print; + } +}; diff --git a/src/qtiCommonRenderer/helpers/PortableElement.js b/src/qtiCommonRenderer/helpers/PortableElement.js new file mode 100644 index 00000000..6fc276ea --- /dev/null +++ b/src/qtiCommonRenderer/helpers/PortableElement.js @@ -0,0 +1,47 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2015 (original work) Open Assessment Technologies SA ; + */ + +/** + * Portable element helper + */ + +var imgSrcPattern = /(]*src=["'])([^"']+)(["'])/gi; + +/** + * Replace all identified relative media urls by the absolute one. + * For now only images are supported. + * + * @param {String} html - the html to parse + * @param {Object} the renderer + * @returns {String} the html without updated URLs + */ +function fixMarkupMediaSources(html, renderer) { + html = html || ''; + + return html.replace(imgSrcPattern, function(substr, $1, $2, $3) { + var resolved = renderer.resolveUrl($2) || $2; + return $1 + resolved + $3; + }); +} + +/** + * @exports taoQtiItem/qtiCommonRenderer/helpers/PortableElement + */ +export default { + fixMarkupMediaSources: fixMarkupMediaSources +}; diff --git a/src/qtiCommonRenderer/helpers/ckConfigurator.js b/src/qtiCommonRenderer/helpers/ckConfigurator.js new file mode 100644 index 00000000..fcbc0d0a --- /dev/null +++ b/src/qtiCommonRenderer/helpers/ckConfigurator.js @@ -0,0 +1,50 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2015 (original work) Open Assessment Technologies SA ; + */ +/** + * @author Jean-Sébastien Conan + */ +import ckConfigurator from 'ui/ckeditor/ckConfigurator'; + +/** + * Generate a configuration object for CKEDITOR + * + * @param editor instance of ckeditor + * @param toolbarType block | inline | flow | qtiBlock | qtiInline | qtiFlow | reset to get back to normal + * @param {Object} [options] - is based on the CKEDITOR config object with some additional sugar + * Note that it's here you need to add parameters for the resource manager. + * Some options are not covered in http://docs.ckeditor.com/#!/api/CKEDITOR.config + * @param [options.dtdOverrides] - @see dtdOverrides which pre-defines them + * @param {Object} [options.positionedPlugins] - @see ckConfig.positionedPlugins + * @param {Boolean} [options.qtiImage] - enables the qtiImage plugin + * @param {Boolean} [options.qtiInclude] - enables the qtiInclude plugin + * @param {Boolean} [options.underline] - enables the underline plugin + * @param {Boolean} [options.mathJax] - enables the mathJax plugin + * + * @see http://docs.ckeditor.com/#!/api/CKEDITOR.config + */ +var getConfig = function(editor, toolbarType, options) { + options = options || {}; + + options.underline = true; + + return ckConfigurator.getConfig(editor, toolbarType, options); +}; + +export default { + getConfig: getConfig +}; diff --git a/src/qtiCommonRenderer/helpers/container.js b/src/qtiCommonRenderer/helpers/container.js new file mode 100644 index 00000000..63837b96 --- /dev/null +++ b/src/qtiCommonRenderer/helpers/container.js @@ -0,0 +1,143 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2015 (original work) Open Assessment Technologies SA ; + */ +import _ from 'lodash'; +import $ from 'jquery'; +import Element from 'taoQtiItem/qtiItem/core/Element'; + +//containers are cached, so do not forget to remove them. +var _containers = {}; +var _$containerContext = $(); + +/** + * Build the selector for your element (from the element serial) + * @private + * @param {QtiElement} element + * @returns {String} the selector + */ +var _getSelector = function(element) { + var serial = element.getSerial(), + selector = '[data-serial=' + serial + ']'; + + if (Element.isA(element, 'choice')) { + selector = '.qti-choice' + selector; + } else if (Element.isA(element, 'interaction')) { + selector = '.qti-interaction' + selector; + } + + return selector; +}; + +/** + * Helps you to retrieve the DOM element (as a jquery element) + * @exports taoQtiItem/qtiCommonRenderer/helpers/containerHelper + */ +var containerHelper = { + /** + * Set a global scope to look for element container + * @param {jQueryElement} [$scope] - if you want to retrieve the element in a particular scope or context + */ + setContext: function($scope) { + _$containerContext = $scope; + }, + + /** + * Get the container of the given element + * @param {QtiElement} element - the QTI Element to find the container for + * @param {jQueryElement} [$scope] - if you want to retrieve the element in a particular scope or context + * @returns {jQueryElement} the container + */ + get: function(element, $scope) { + var serial = element.getSerial(); + if ($scope instanceof $ && $scope.length) { + //find in the given context + return $scope.find(_getSelector(element)); + } else if (_$containerContext instanceof $ && _$containerContext.length) { + //find in the globally set context + return _$containerContext.find(_getSelector(element)); + } else if (!_containers[serial] || !_containers[serial].length) { + //find in the global context + _containers[serial] = $(_getSelector(element)); + } + + return _containers[serial]; + }, + + /** + * getContainer use a cache to store elements. This methods helps you to purge it. + * @param {Element} element - find the container of this element + */ + reset: function(element) { + if (element instanceof Element && _containers[element.getSerial()]) { + _containers = _.omit(_containers, element.getSerial()); + } + }, + + /** + * Clear the containers cache + */ + clear: function clear() { + _containers = {}; + _$containerContext = $(); + }, + + /** + * Trigger an event on the element's container + * @param {String} eventType - the name of the event + * @param {QtiElement} element - find the container of this element + * @param {Array} [data] - data to give to the event + */ + trigger: function(eventType, element, data) { + if (eventType) { + if (data && !_.isArray(data)) { + data = [data]; + } + this.get(element).trigger(eventType, data); + } + }, + + /** + * Alias to trigger a responseChange Event from an interaction + * @param {QtiElement} interaction - the interaction that had a response changed + * @param {Object} [extraData] - additionnal data to give to the event + */ + triggerResponseChangeEvent: function(interaction, extraData) { + this.trigger('responseChange', interaction, [ + { + interaction: interaction, + response: interaction.getResponse() + }, + extraData + ]); + }, + + /** + * Make all links to opens in another tab/window + * @param {jQueryElement} $container + */ + targetBlank: function($container) { + $container.on('click', 'a', function(e) { + e.preventDefault(); + var href = $(this).attr('href'); + if (href && href.match(/^http/i)) { + window.open(href, '_blank'); + } + }); + } +}; + +export default containerHelper; diff --git a/src/qtiCommonRenderer/helpers/instructions/Instruction.js b/src/qtiCommonRenderer/helpers/instructions/Instruction.js new file mode 100644 index 00000000..87de58c7 --- /dev/null +++ b/src/qtiCommonRenderer/helpers/instructions/Instruction.js @@ -0,0 +1,114 @@ +import _ from 'lodash'; +import util from 'taoQtiItem/qtiItem/helper/util'; +import instructionTpl from 'taoQtiItem/qtiCommonRenderer/tpl/instruction'; + +var _notificationLevels = ['info', 'warning', 'error', 'success']; + +var Instruction = function(interaction, message, callback) { + this.interaction = interaction; + this.defaultMessage = message || ''; + this.currentMessage = ''; + this.level = 'info'; + this.serial = util.buildSerial('instruction_'); + this.callback = callback; + this.$dom = null; + this.state = false; +}; + +Instruction.isValidLevel = function(level) { + return _.indexOf(_notificationLevels, level) >= 0; +}; + +Instruction.prototype.setState = function(state) { + this.state = state; +}; + +Instruction.prototype.checkState = function(state) { + return this.state === state; +}; + +Instruction.prototype.getId = function() { + return this.serial; +}; + +Instruction.prototype.create = function($container) { + $container.append( + instructionTpl({ + message: this.defaultMessage, + serial: this.serial + }) + ); + + this.$dom = $container.find('#' + this.serial); +}; + +Instruction.prototype.update = function(options) { + var level = options && options.level ? options.level : '', + message = options && options.message ? options.message : '', + timeout = options && options.timeout ? options.timeout : 0, + start = options && typeof options.start === 'function' ? options.start : null, + stop = options && typeof options.stop === 'function' ? options.stop : null; + + if (level && Instruction.isValidLevel(level)) { + this.$dom.removeClass('feedback-' + this.level).addClass('feedback-' + level); + this.$dom + .find('.icon') + .removeClass('icon-' + this.level) + .addClass('icon-' + level); + this.level = level; + } + + if (message) { + this.$dom.find('.instruction-message').html(message); + this.currentMessage = message; + } + + if (timeout) { + var _this = this; + if (start) { + start.call(_this); + } + _this.timer = setTimeout(function() { + if (stop) { + stop.call(_this); + } + _this.timer = null; + }, timeout); + } +}; + +Instruction.prototype.setLevel = function(level, timeout) { + var options = { + level: level + }; + + if (timeout) { + options.timeout = parseInt(timeout); + options.stop = function() { + this.setLevel('info'); + }; + } + + this.update(options); +}; + +Instruction.prototype.getLevel = function() { + return this.level; +}; + +Instruction.prototype.setMessage = function(message, timeout) { + this.update({ message: message, timeout: timeout }); +}; + +Instruction.prototype.reset = function() { + this.update({ level: 'info', message: this.defaultMessage }); + this.state = false; +}; + +Instruction.prototype.validate = function(data) { + if (typeof this.callback === 'function') { + this.callback.call(this, data); + } +}; + +export default Instruction; diff --git a/src/qtiCommonRenderer/helpers/instructions/instructionManager.js b/src/qtiCommonRenderer/helpers/instructions/instructionManager.js new file mode 100644 index 00000000..181f7972 --- /dev/null +++ b/src/qtiCommonRenderer/helpers/instructions/instructionManager.js @@ -0,0 +1,243 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ + +/** + * @author Sam Sipasseuth + * @author Bertrand Chevrier + */ +import _ from 'lodash'; +import $ from 'jquery'; +import __ from 'i18n'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; +import Instruction from 'taoQtiItem/qtiCommonRenderer/helpers/instructions/Instruction'; +import notifTpl from 'taoQtiItem/qtiCommonRenderer/tpl/notification'; + +//stores the instructions +var _instructions = {}; + +/** + * The instruction manager helps you in managing instructions and + * constraints on a QTI Element, usually an interaction or a choice. + * + * @exports qtiCommonRenderer/helpers/Instructions/instructionManager + */ +var instructionManager = { + /** + * Validate the instructions of an element + * @param {QtiElement} element - a QTI element like an interaction or a choice + * @param {Object} [data] - any data to give to the instructions + */ + validateInstructions: function(element, data) { + var serial = element.getSerial(); + if (_instructions[serial]) { + _.each(_instructions[serial], function(instruction) { + instruction.validate(data || {}); + }); + } + }, + + /** + * Add a new instructions to an element + * @param {QtiElement} element - a QTI element like an interaction or a choice + * @param {String} message - the message to give to display to the user when the instruction is validated + * @param {Function} validateCallback - how to validate the instruction + * @returns {Instruction} the created instruction + */ + appendInstruction: function(element, message, validateCallback) { + var serial = element.getSerial(), + instruction = new Instruction(element, message, validateCallback); + + if (!_instructions[serial]) { + _instructions[serial] = {}; + } + _instructions[serial][instruction.getId()] = instruction; + + instruction.create($('.instruction-container', containerHelper.get(element))); + + return instruction; + }, + + /** + * Remove instructions from an element + * @param {QtiElement} element - a QTI element like an interaction or a choice + */ + removeInstructions: function(element) { + _instructions[element.getSerial()] = {}; + containerHelper + .get(element) + .find('.instruction-container') + .empty(); + }, + + /** + * Reset the instructions states for an element (but keeps configuration) + * @param {Object} element - the qti object, ie. interaction, choice, etc. + */ + resetInstructions: function(element) { + var serial = element.getSerial(); + if (_instructions[serial]) { + _.each(_instructions[serial], function(instruction) { + instruction.reset(); + }); + } + }, + + /** + * Default instuction set with a min/max constraints. + * @param {Object} interaction + * @param {jQueryElement} $container + * @param {Object} options + * @param {Number} [options.min = 0] - + * @param {Number} [options.max = 0] - + * @param {Function} options.getResponse - a ref to a function that get the raw response (array) from the interaction in parameter + * @param {Function} [options.onError] - called by once an error occurs with validateInstruction data in parameters + */ + minMaxChoiceInstructions: function(interaction, options) { + var self = this, + min = options.min || 0, + max = options.max || 0, + getResponse = options.getResponse, + onError = options.onError || _.noop(), + choiceCount = options.choiceCount === false ? false : _.size(interaction.getChoices()), + minInstructionSet = false, + msg; + + if (!_.isFunction(getResponse)) { + throw 'invalid parameter getResponse'; + } + + //if maxChoice = 0, inifinite choice possible + if (max > 0 && (choiceCount === false || max < choiceCount)) { + if (max === min) { + minInstructionSet = true; + msg = + max <= 1 + ? __('You must select exactly %d choice', max) + : __('You must select exactly %d choices', max); + + self.appendInstruction(interaction, msg, function(data) { + if (getResponse(interaction).length >= max) { + this.setLevel('success'); + if (this.checkState('fulfilled')) { + this.update({ + level: 'warning', + message: __('Maximum choices reached'), + timeout: 2000, + start: function() { + onError(data); + }, + stop: function() { + this.update({ level: 'success', message: msg }); + } + }); + } + this.setState('fulfilled'); + } else { + this.reset(); + } + }); + } else if (max > min) { + msg = + max <= 1 + ? __('You can select maximum %d choice', max) + : __('You can select maximum %d choices', max); + self.appendInstruction(interaction, msg, function(data) { + if (getResponse(interaction).length >= max) { + this.setLevel('success'); + this.setMessage(__('Maximum choices reached')); + if (this.checkState('fulfilled')) { + this.update({ + level: 'warning', + timeout: 2000, + start: function() { + onError(data); + }, + stop: function() { + this.setLevel('info'); + } + }); + } + + this.setState('fulfilled'); + } else { + this.reset(); + } + }); + } + } + + if (!minInstructionSet && min > 0 && (choiceCount === false || min < choiceCount)) { + msg = + min <= 1 + ? __('You must select at least %d choice', min) + : __('You must select at least %d choices', min); + self.appendInstruction(interaction, msg, function() { + if (getResponse(interaction).length >= min) { + this.setLevel('success'); + } else { + this.reset(); + } + }); + } + }, + + /** + * Appends a instruction notification message + * @deprecated in favor of instructions + * @param {QtiElement} element - a QTI element like an interaction or a choice + * @param {String} message - the message to give to display + * @param {String} [level = 'info'] - the notification level in info, success, error or warning + */ + appendNotification: function(element, message, level) { + level = level || 'info'; + + if (Instruction.isValidLevel(level)) { + var $container = containerHelper.get(element); + + $container.find('.notification-container').prepend( + notifTpl({ + level: level, + message: message + }) + ); + + var $notif = $container.find('.item-notification:first'); + var _remove = function() { + $notif.fadeOut(); + }; + + $notif.find('.close-trigger').on('click', _remove); + setTimeout(_remove, 2000); + + return $notif; + } + }, + + /** + * Removes all the displayed notifications + * @deprecated in favor of instructions + */ + removeNotifications: function(element) { + containerHelper + .get(element) + .find('.item-notification') + .remove(); + } +}; +export default instructionManager; diff --git a/src/qtiCommonRenderer/helpers/itemStylesheetHandler.js b/src/qtiCommonRenderer/helpers/itemStylesheetHandler.js new file mode 100644 index 00000000..47dffcbc --- /dev/null +++ b/src/qtiCommonRenderer/helpers/itemStylesheetHandler.js @@ -0,0 +1,86 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2015 (original work) Open Assessment Technologies SA ; + */ + +/** + * TODO this code should be merged with the theme loader + */ +import $ from 'jquery'; +import _ from 'lodash'; + +//throttle events because of the loop +var informLoaded = _.throttle( + function() { + $(document).trigger('customcssloaded.styleeditor'); + }, + 10, + { leading: false } +); + +/** + * Attach QTI Stylesheets to the document + * + * @param {Array} stylesheets - the QTI model stylesheets + * @fires customcssloaded.styleeditor on document 10ms after stylesheets are loaded + */ +var attach = function attach(stylesheets) { + var $head = $('head'); + + //fallback + if (!$head.length) { + $head = $('body'); + } + + // relative links with cache buster + _(stylesheets).forEach(function(stylesheet) { + var $link, href; + + //if the href is something + if (stylesheet.attr('href')) { + $link = $(stylesheet.render()); + + //get the resolved href once rendererd + href = $link.attr('href'); + + //we need to set the href after the link is appended to the head (for our dear IE) + $link.removeAttr('href').attr('href', href); + + $link.one('load', informLoaded).appendTo($head); + } + }); +}; + +/** + * Remove QTI Stylesheets from the document + * + * @param {Array} stylesheets - the QTI model stylesheets + */ +var detach = function detach(stylesheets) { + _(stylesheets).forEach(function(stylesheet) { + if (stylesheet.serial) { + $('link[data-serial="' + stylesheet.serial + '"]').remove(); + } + }); +}; + +/** + * @exports taoQtiItem/qtiCommonRenderer/helpers/itemStylesheetHandler + */ +export default { + attach: attach, + detach: detach +}; diff --git a/src/qtiCommonRenderer/helpers/patternMask.js b/src/qtiCommonRenderer/helpers/patternMask.js new file mode 100644 index 00000000..e06845c6 --- /dev/null +++ b/src/qtiCommonRenderer/helpers/patternMask.js @@ -0,0 +1,79 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014-2016 (original work) Open Assessment Technologies SA ; + */ + +var regexChar = /\^\[\\s\\S\]\{\d+\,(\d+)\}\$/, + regexWords = /\^\(\?\:\(\?\:\[\^\\s\\:\\!\\\?\\\;\\\…\\\€\]\+\)\[\\s\\:\\!\\\?\\;\\\…\\\€\]\*\)\{\d+\,(\d+)\}\$/; + +var patternMaskHelper = { + /** + * Parse the pattern string and according to the given type, try to extract the associate number + * + * @param {String} pattern - the pattern string to be parsed + * @param {String} type - words or chars + * @returns {*} + */ + parsePattern: function parsePattern(pattern, type) { + if (pattern === undefined || pattern === null) { + return null; + } + if (type === 'words') { + //expre = /^(?:(?:[^\s\:\!\?\;\…\€]+)[\s\:\!\?\;\…\€]*){0,3}$/; + var result = pattern.match(regexWords); + + if (result !== null && result.length > 1) { + return result[1]; + } else { + return null; + } + } else if (type === 'chars') { + // This is the original regExp generated by our code + // expre = /^[\s\S]{0,10}$/; + // and we will try to extract the top limit from it with this regexp + // which is mostly just escaped version of the first one. + var result = pattern.match(regexChar); + + if (result !== null && result.length > 1) { + return result[1]; + } else { + return null; + } + } else { + return null; + } + }, + /** + * Reverse function of parsePattern for word type + * + * @param {Number} max + * @returns {string} + */ + createMaxWordPattern: function createMaxWordPattern(max) { + return '^(?:(?:[^\\s\\:\\!\\?\\;\\…\\€]+)[\\s\\:\\!\\?\\;\\…\\€]*){0,' + max.toString() + '}$'; + }, + /** + * Reverse function of parsePattern for char type + * + * @param {Number} max + * @returns {string} + */ + createMaxCharPattern: function createMaxCharPattern(max) { + return '^[\\s\\S]{0,' + max.toString() + '}$'; + } +}; + +export default patternMaskHelper; diff --git a/src/qtiCommonRenderer/helpers/sizeAdapter.js b/src/qtiCommonRenderer/helpers/sizeAdapter.js new file mode 100644 index 00000000..992b9a93 --- /dev/null +++ b/src/qtiCommonRenderer/helpers/sizeAdapter.js @@ -0,0 +1,61 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2015 (original work) Open Assessment Technologies SA; + * + */ +import $ from 'jquery'; +import adaptSize from 'util/adaptSize'; +import 'ui/waitForMedia'; + +export default { + /** + * Resize jQueryElement that have changed their dimensions due to a change of the content + * + * @param {jQueryElement|widget} target + */ + adaptSize: function(target) { + var $elements; + var $container; + + switch (true) { + // widget + case typeof target.$container !== 'undefined': + $elements = target.$container.find('.add-option, .result-area .target, .choice-area .qti-choice'); + $container = target.$container; + break; + + // jquery elements + default: + $elements = target; + $container = $($elements) + .first() + .parent(); + } + + $container.waitForMedia(function() { + adaptSize.height($elements); + document.addEventListener( + 'load', + function(e) { + if (e.target.rel === 'stylesheet') { + adaptSize.height($elements); + } + }, + true + ); + }); + } +}; diff --git a/src/qtiCommonRenderer/helpers/uploadMime.js b/src/qtiCommonRenderer/helpers/uploadMime.js new file mode 100644 index 00000000..52affa16 --- /dev/null +++ b/src/qtiCommonRenderer/helpers/uploadMime.js @@ -0,0 +1,137 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2015 (original work) Open Assessment Technologies SA ; + */ +import _ from 'lodash'; +import __ from 'i18n'; + +var uploadMime = { + /** + * @TODO these mime types are not up-to-date, in particular the MS ones + * refer to http://filext.com/faq/office_mime_types.php + * @type [{getMimeTypes: getMimeTypes}] + */ + getMimeTypes: function getMimeTypes() { + return [ + { mime: 'application/zip', label: __('ZIP archive'), equivalent: ['application/x-zip-compressed'] }, + { mime: 'text/plain', label: __('Plain text') }, + { mime: 'application/pdf', label: __('PDF file') }, + { mime: 'image/jpeg', label: __('JPEG image') }, + { mime: 'image/png', label: __('PNG image') }, + { mime: 'image/gif', label: __('GIF image') }, + { mime: 'image/svg+xml', label: __('SVG image') }, + { mime: 'audio/mpeg', label: __('MPEG audio'), equivalent: ['audio/mp3'] }, + { mime: 'audio/x-ms-wma', label: __('Windows Media audio') }, + { mime: 'audio/x-wav', label: __('WAV audio'), equivalent: ['audio/wav'] }, + { mime: 'video/mpeg', label: __('MPEG video') }, + { mime: 'video/mp4', label: __('MP4 video') }, + { mime: 'video/quicktime', label: __('Quicktime video') }, + { mime: 'video/x-ms-wmv', label: __('Windows Media video') }, + { mime: 'video/x-flv', label: __('Flash video') }, + { mime: 'text/csv', label: __('CSV file') }, + { + mime: 'application/msword', + label: __('Microsoft Word'), + equivalent: ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'] + }, + { + mime: 'application/vnd.ms-excel', + label: __('Microsoft Excel'), + equivalent: ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'] + }, + { + mime: 'application/vnd.ms-powerpoint', + label: __('Microsoft Powerpoint'), + equivalent: ['application/vnd.openxmlformats-officedocument.presentationml.presentation'] + } + ]; + }, + + /** + * Set the expected types in the format according to the number of types + * + * @param {Object} interaction + * @param {Array} types + */ + setExpectedTypes: function setExpectedTypes(interaction, types) { + var classes = interaction.attr('class') || ''; + classes = classes.replace(/x-tao-upload-type-[-_a-zA-Z+.0-9]*/g, '').trim(); + interaction.attr('class', classes); + interaction.removeAttr('type'); + + if (!types) { + return; + } + + if (types.length === 1) { + //if there is only one value set into the qti standard type attribute + if (types[0] !== 'any/kind') { + interaction.attr('type', types[0]); + } + } else { + //if there is more than one value, set into into TAO specific css classes + //qti 2.1 xsd indeed does not allow comma-separated multi mime type value for the attribute "type + interaction.attr( + 'class', + _.reduce( + types, + function(acc, selectedType) { + return acc + ' x-tao-upload-type-' + selectedType.replace('/', '_'); + }, + classes + ).trim() + ); + } + }, + + /** + * Return the array of authorized mime types + * It first get the standard "type" attribute value. + * If not set search the TAO specific type information recorded in the class attributes, + * qti 2.1 xsd indeed does not allow comma-separated multi mime type value for the attribute "type" + * @param {Object} interaction - standard QTI interaction model object + * @param {Boolean} [includeEquivalents] - enable including all recognized as equivalent types + * @returns {Array} + */ + getExpectedTypes: function getExpectedTypes(interaction, includeEquivalents) { + var classes = interaction.attr('class') || ''; + var types = []; + var mimes; + var equivalents = []; + if (interaction.attr('type')) { + types.push(interaction.attr('type')); + } else { + classes.replace(/x-tao-upload-type-([-_a-zA-Z+.0-9]*)/g, function($0, type) { + types.push(type.replace('_', '/').trim()); + }); + } + + // add in equivalent mimetypes to the list of expected types + if (includeEquivalents === true) { + mimes = uploadMime.getMimeTypes(); + _.forEach(types, function(mime) { + var mimeData = _.find(mimes, { mime: mime }); + if (mimeData && _.isArray(mimeData.equivalent)) { + equivalents = _.union(equivalents, mimeData.equivalent); + } + }); + } + + return _.union(types, equivalents); + } +}; + +export default uploadMime; diff --git a/src/qtiCommonRenderer/renderers/Container.js b/src/qtiCommonRenderer/renderers/Container.js new file mode 100644 index 00000000..bb4d6aab --- /dev/null +++ b/src/qtiCommonRenderer/renderers/Container.js @@ -0,0 +1,24 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + */ + +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/container'; + +export default { + qtiClass: '_container', + template: tpl +}; diff --git a/src/qtiCommonRenderer/renderers/Img.js b/src/qtiCommonRenderer/renderers/Img.js new file mode 100644 index 00000000..b09837c5 --- /dev/null +++ b/src/qtiCommonRenderer/renderers/Img.js @@ -0,0 +1,32 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + */ +import Promise from 'core/promise'; +import 'ui/waitForMedia'; +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/img'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; + +export default { + qtiClass: 'img', + template: tpl, + getContainer: containerHelper.get, + render: function render(img, data) { + return new Promise(function(resolve, reject) { + containerHelper.get(img).waitForMedia(resolve); + }); + } +}; diff --git a/src/qtiCommonRenderer/renderers/Include.js b/src/qtiCommonRenderer/renderers/Include.js new file mode 100644 index 00000000..ea0cff26 --- /dev/null +++ b/src/qtiCommonRenderer/renderers/Include.js @@ -0,0 +1,23 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + */ +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/include'; + +export default { + qtiClass: 'include', + template: tpl +}; diff --git a/src/qtiCommonRenderer/renderers/Item.js b/src/qtiCommonRenderer/renderers/Item.js new file mode 100644 index 00000000..409c43a1 --- /dev/null +++ b/src/qtiCommonRenderer/renderers/Item.js @@ -0,0 +1,56 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technologies SA; + */ + +/** + * Define the Item Element Renderer + */ +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/item'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; +import itemStylesheetHandler from 'taoQtiItem/qtiCommonRenderer/helpers/itemStylesheetHandler'; + +export default { + qtiClass: 'assessmentItem', + template: tpl, + getContainer: containerHelper.get, + + /** + * Rendering initializations for the item + * @param {Object} item - the item model + */ + render: function render(item) { + //target blank for all + containerHelper.targetBlank(containerHelper.get(item)); + + //add stylesheets + itemStylesheetHandler.attach(item.stylesheets); + }, + + /** + * Unrender + * @param {Object} item - the item model + */ + destroy: function destroy(item) { + //clear the container cache + containerHelper.clear(); + + //detach stylesheets + if (item.stylesheets) { + itemStylesheetHandler.detach(item.stylesheets); + } + } +}; diff --git a/src/qtiCommonRenderer/renderers/Math.js b/src/qtiCommonRenderer/renderers/Math.js new file mode 100644 index 00000000..e972c3e9 --- /dev/null +++ b/src/qtiCommonRenderer/renderers/Math.js @@ -0,0 +1,62 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ + +/** + * Math common renderer + * + * @author Sam Sipasseuth + * @author Bertrand Chevrier + */ +import _ from 'lodash'; +import Promise from 'core/promise'; +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/math'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; +import MathJax from 'mathJax'; + +// Do not wait between rendering each individual math element +// http://docs.mathjax.org/en/latest/api/hub.html +if (typeof MathJax !== 'undefined' && MathJax) { + MathJax.Hub.processSectionDelay = 0; +} + +export default { + qtiClass: 'math', + template: tpl, + getContainer: containerHelper.get, + render: function render(math) { + return new Promise(function(resolve) { + if (typeof MathJax !== 'undefined' && MathJax) { + //MathJax needs to be exported globally to integrate with tools like TTS, it's weird... + if (!window.MathJax) { + window.MathJax = MathJax; + } + _.defer(function() { + //defer execution fix some rendering issue in chrome + + MathJax.Hub.Queue(['Typeset', MathJax.Hub, containerHelper.get(math).parent()[0]]); + + //@see http://docs.mathjax.org/en/latest/advanced/typeset.html + MathJax.Hub.Queue(resolve); + }); + } else { + resolve(); + } + }); + } +}; diff --git a/src/qtiCommonRenderer/renderers/ModalFeedback.js b/src/qtiCommonRenderer/renderers/ModalFeedback.js new file mode 100644 index 00000000..52e5fe06 --- /dev/null +++ b/src/qtiCommonRenderer/renderers/ModalFeedback.js @@ -0,0 +1,65 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014-2019 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ +import _ from 'lodash'; +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/modalFeedback'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; +import coreContainerHelper from 'taoQtiItem/qtiItem/helper/container'; +import 'ui/waitForMedia'; +import 'ui/modal'; + +var modalFeedbackRenderer = { + qtiClass: 'modalFeedback', + template: tpl, + getContainer: containerHelper.get, + width: 600, + getData: function(fb, data) { + data.feedbackStyle = coreContainerHelper.getEncodedData(fb, 'modalFeedback'); + return data; + }, + render: function(modalFeedback, data) { + var $modal = containerHelper.get(modalFeedback); + + $modal.waitForMedia(function() { + //when we are sure that media is loaded: + $modal + .on('opened.modal', function() { + //set item body height + var $itemBody = containerHelper.get(modalFeedback.getRootElement()).children('.qti-itemBody'); + var requiredHeight = $modal.outerHeight() + parseInt($modal.css('top')); + if (requiredHeight > $itemBody.height()) { + $itemBody.height(requiredHeight); + } + }) + .on('closed.modal', function() { + data = data || {}; + + if (_.isFunction(data.callback)) { + data.callback.call(this); + } + }) + .modal({ + startClosed: false, + minHeight: modalFeedbackRenderer.minHeight, + width: modalFeedbackRenderer.width + }); + }); + } +}; + +export default modalFeedbackRenderer; diff --git a/src/qtiCommonRenderer/renderers/Object.js b/src/qtiCommonRenderer/renderers/Object.js new file mode 100644 index 00000000..9105f5be --- /dev/null +++ b/src/qtiCommonRenderer/renderers/Object.js @@ -0,0 +1,43 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technologies SA + */ + +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/object'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; +import 'ui/previewer'; + +export default { + qtiClass: 'object', + template: tpl, + getContainer: containerHelper.get, + render: function(obj) { + var $container = containerHelper.get(obj); + var previewOptions = { + url: obj.renderer.resolveUrl(obj.attr('data')), + mime: obj.attr('type') + }; + if (obj.attr('height')) { + previewOptions.height = obj.attr('height'); + } + if (obj.attr('width')) { + previewOptions.width = obj.attr('width'); + } + if (previewOptions.url && previewOptions.mime) { + $container.previewer(previewOptions); + } + } +}; diff --git a/src/qtiCommonRenderer/renderers/PortableInfoControl.js b/src/qtiCommonRenderer/renderers/PortableInfoControl.js new file mode 100644 index 00000000..417660cd --- /dev/null +++ b/src/qtiCommonRenderer/renderers/PortableInfoControl.js @@ -0,0 +1,189 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2015 (original work) Open Assessment Technologies SA + * + */ + +/** + * Portable Info Control Common Renderer + */ +import _ from 'lodash'; +import Promise from 'core/promise'; +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/infoControl'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; +import PortableElement from 'taoQtiItem/qtiCommonRenderer/helpers/PortableElement'; +import qtiInfoControlContext from 'qtiInfoControlContext'; +import util from 'taoQtiItem/qtiItem/helper/util'; +import icRegistry from 'taoQtiItem/portableElementRegistry/icRegistry'; + +/** + * Get the PIC instance associated to the infoControl object + * If none exists, create a new one based on the PIC typeIdentifier + * + * @param {Object} infoControl - the js object representing the infoControl + * @returns {Object} PIC instance + */ +var _getPic = function(infoControl) { + var typeIdentifier, + pic = infoControl.data('pic') || undefined; + + if (!pic) { + typeIdentifier = infoControl.typeIdentifier; + pic = qtiInfoControlContext.createPciInstance(typeIdentifier); + + if (pic) { + //binds the PIC instance to TAO infoControl object and vice versa + infoControl.data('pic', pic); + pic._taoInfoControl = infoControl; + } else { + throw 'no custom infoControl hook found for the type ' + typeIdentifier; + } + } + + return pic; +}; + +/** + * Execute javascript codes to bring the infoControl to life. + * At this point, the html markup must already be ready in the document. + * + * It is done in 5 steps : + * 1. ensure the context is configured correctly + * 2. require all required libs + * 3. create a pic instance based on the infoControl model + * 4. initialize the rendering + * 5. restore full state if applicable + * + * @param {Object} infoControl + * @param {Object} [options] + */ +var render = function(infoControl, options) { + var self = this; + options = options || {}; + return new Promise(function(resolve, reject) { + var state = {}; //@todo pass state and response to renderer here: + var id = infoControl.attr('id'); + var typeIdentifier = infoControl.typeIdentifier; + var config = infoControl.properties; + var $dom = containerHelper.get(infoControl).children(); + var assetManager = self.getAssetManager(); + + icRegistry + .loadRuntimes() + .then(function() { + var requireEntries = []; + var runtime = icRegistry.getRuntime(typeIdentifier); + + if (!runtime) { + return reject('The runtime for the pic cannot be found : ' + typeIdentifier); + } + + //load the entrypoint, becomes optional per IMS PCI v1 + if (runtime.hook) { + requireEntries.push(runtime.hook.replace(/\.js$/, '')); + } + + //load required libraries + _.forEach(runtime.libraries, function(module) { + requireEntries.push(module.replace(/\.js$/, '')); + }); + + //load stylesheets + _.forEach(runtime.stylesheets, function(stylesheet) { + requireEntries.push('css!' + stylesheet.replace(/\.css$/, '')); + }); + + //load the entrypoint + require(requireEntries, function() { + var pic = _getPic(infoControl); + var picAssetManager = { + resolve: function resolve(url) { + var resolved = assetManager.resolveBy('portableElementLocation', url); + if (resolved === url) { + return assetManager.resolveBy('baseUrl', url); + } else { + return resolved; + } + } + }; + + if (pic) { + //call pic initialize() to render the pic + pic.initialize(id, $dom[0], config, picAssetManager); + //restore context (state + response) + pic.setSerializedState(state); + + return resolve(); + } + + return reject('Unable to initialize pic : ' + id); + }, reject); + }) + .catch(function(error) { + reject('Error loading runtime : ' + id); + }); + }); +}; + +/** + * Reverse operation performed by render() + * After this function is executed, only the inital naked markup remains + * Event listeners are removed and the state and the response are reset + * + * @param {Object} infoControl + */ +var destroy = function destroy(infoControl) { + _getPic(infoControl).destroy(); +}; + +/** + * Restore the state of the infoControl from the serializedState. + * + * @param {Object} infoControl - the element instance + * @param {Object} state - the state to set + */ +var setState = function setState(infoControl, state) { + _getPic(infoControl).setSerializedState(state); +}; + +/** + * Get the current state of the infoControl as a string. + * It enables saving the state for later usage. + * + * @param {Object} infoControl - the element instance + * @returns {Object} the state + */ +var getState = function getState(infoControl) { + return _getPic(infoControl).getSerializedState(); +}; + +export default { + qtiClass: 'infoControl', + template: tpl, + getData: function(infoControl, data) { + //remove ns + fix media file path + var markup = data.markup; + markup = util.removeMarkupNamespaces(markup); + markup = PortableElement.fixMarkupMediaSources(markup, this); + data.markup = markup; + return data; + }, + render: render, + getContainer: containerHelper.get, + destroy: destroy, + getState: getState, + setState: setState +}; diff --git a/src/qtiCommonRenderer/renderers/PrintedVariable.js b/src/qtiCommonRenderer/renderers/PrintedVariable.js new file mode 100644 index 00000000..e6a63361 --- /dev/null +++ b/src/qtiCommonRenderer/renderers/PrintedVariable.js @@ -0,0 +1,28 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2017 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + */ +/** + * @author Christophe Noël + */ +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/printedVariable'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; + +export default { + qtiClass: 'printedVariable', + template: tpl, + getContainer: containerHelper.get +}; diff --git a/src/qtiCommonRenderer/renderers/Renderer.js b/src/qtiCommonRenderer/renderers/Renderer.js new file mode 100644 index 00000000..2412ce4b --- /dev/null +++ b/src/qtiCommonRenderer/renderers/Renderer.js @@ -0,0 +1,25 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technologies SA; + */ + +/** + * Define the Qti Item Common Renderer + */ +import Renderer from 'taoQtiItem/qtiRunner/core/Renderer'; +import config from 'taoQtiItem/qtiCommonRenderer/renderers/config'; + +export default Renderer.build(config.locations, config.name, config.options); diff --git a/src/qtiCommonRenderer/renderers/RubricBlock.js b/src/qtiCommonRenderer/renderers/RubricBlock.js new file mode 100644 index 00000000..8147444d --- /dev/null +++ b/src/qtiCommonRenderer/renderers/RubricBlock.js @@ -0,0 +1,31 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + */ +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/rubricBlock'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; + +export default { + qtiClass: 'rubricBlock', + getContainer: containerHelper.get, + template: tpl, + getData: function getData(rubric, data) { + if (rubric.isEmpty()) { + data.empty = true; + } + return data; + } +}; diff --git a/src/qtiCommonRenderer/renderers/Stylesheet.js b/src/qtiCommonRenderer/renderers/Stylesheet.js new file mode 100644 index 00000000..987f3213 --- /dev/null +++ b/src/qtiCommonRenderer/renderers/Stylesheet.js @@ -0,0 +1,25 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + */ +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/stylesheet'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; + +export default { + qtiClass: 'stylesheet', + template: tpl, + getContainer: containerHelper.get +}; diff --git a/src/qtiCommonRenderer/renderers/Table.js b/src/qtiCommonRenderer/renderers/Table.js new file mode 100644 index 00000000..004c6fb0 --- /dev/null +++ b/src/qtiCommonRenderer/renderers/Table.js @@ -0,0 +1,28 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2017 (original work) Open Assessment Technologies SA; + */ +/** + * @author Christophe Noël + */ +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/table'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; + +export default { + qtiClass: 'table', + getContainer: containerHelper.get, + template: tpl +}; diff --git a/src/qtiCommonRenderer/renderers/Tooltip.js b/src/qtiCommonRenderer/renderers/Tooltip.js new file mode 100644 index 00000000..db14906f --- /dev/null +++ b/src/qtiCommonRenderer/renderers/Tooltip.js @@ -0,0 +1,42 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2017 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + */ +/** + * @author Christophe Noël + */ +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/tooltip'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; +import tooltip from 'ui/tooltip'; + +export default { + qtiClass: '_tooltip', + template: tpl, + getContainer: containerHelper.get, + render: function render(tooltipDOM) { + var $container = containerHelper.get(tooltipDOM); + var renderedTooltip = tooltip.create($container, tooltipDOM.content(), { + theme: 'default', + placement: 'top' + }); + + if ($container.data('$tooltip')) { + $container.data('$tooltip').dispose(); + $container.removeData('$tooltip'); + } + $container.data('$tooltip', renderedTooltip); + } +}; diff --git a/src/qtiCommonRenderer/renderers/choices/Gap.js b/src/qtiCommonRenderer/renderers/choices/Gap.js new file mode 100644 index 00000000..2825b8d5 --- /dev/null +++ b/src/qtiCommonRenderer/renderers/choices/Gap.js @@ -0,0 +1,31 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ + +/** + * @author Sam Sipasseuth + * @author Bertrand Chevrier + */ +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/choices/gap'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; + +export default { + qtiClass: 'gap', + getContainer: containerHelper.get, + template: tpl +}; diff --git a/src/qtiCommonRenderer/renderers/choices/GapImg.js b/src/qtiCommonRenderer/renderers/choices/GapImg.js new file mode 100644 index 00000000..85d76ef0 --- /dev/null +++ b/src/qtiCommonRenderer/renderers/choices/GapImg.js @@ -0,0 +1,31 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ + +/** + * @author Sam Sipasseuth + * @author Bertrand Chevrier + */ +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/choices/gapImg'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; + +export default { + qtiClass: 'gapImg', + getContainer: containerHelper.get, + template: tpl +}; diff --git a/src/qtiCommonRenderer/renderers/choices/GapText.js b/src/qtiCommonRenderer/renderers/choices/GapText.js new file mode 100644 index 00000000..fe3f2311 --- /dev/null +++ b/src/qtiCommonRenderer/renderers/choices/GapText.js @@ -0,0 +1,31 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ + +/** + * @author Sam Sipasseuth + * @author Bertrand Chevrier + */ +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/choices/choice'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; + +export default { + qtiClass: 'gapText', + getContainer: containerHelper.get, + template: tpl +}; diff --git a/src/qtiCommonRenderer/renderers/choices/Hottext.js b/src/qtiCommonRenderer/renderers/choices/Hottext.js new file mode 100644 index 00000000..c8f8a0b3 --- /dev/null +++ b/src/qtiCommonRenderer/renderers/choices/Hottext.js @@ -0,0 +1,31 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ + +/** + * @author Sam Sipasseuth + * @author Bertrand Chevrier + */ +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/choices/hottext'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; + +export default { + qtiClass: 'hottext', + getContainer: containerHelper.get, + template: tpl +}; diff --git a/src/qtiCommonRenderer/renderers/choices/InlineChoice.js b/src/qtiCommonRenderer/renderers/choices/InlineChoice.js new file mode 100644 index 00000000..4d48005d --- /dev/null +++ b/src/qtiCommonRenderer/renderers/choices/InlineChoice.js @@ -0,0 +1,36 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ + +/** + * @author Sam Sipasseuth + * @author Bertrand Chevrier + */ +import _ from 'lodash'; +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/choices/inlineChoice'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; + +export default { + qtiClass: 'inlineChoice', + getContainer: containerHelper.get, + template: tpl, + getData: function getData(choice, data) { + data.body = _.unescape(data.body); + return data; + } +}; diff --git a/src/qtiCommonRenderer/renderers/choices/SimpleAssociableChoice.AssociateInteraction.js b/src/qtiCommonRenderer/renderers/choices/SimpleAssociableChoice.AssociateInteraction.js new file mode 100644 index 00000000..26e405af --- /dev/null +++ b/src/qtiCommonRenderer/renderers/choices/SimpleAssociableChoice.AssociateInteraction.js @@ -0,0 +1,31 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ + +/** + * @author Sam Sipasseuth + * @author Bertrand Chevrier + */ +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/choices/choice'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; + +export default { + qtiClass: 'simpleAssociableChoice.associateInteraction', + getContainer: containerHelper.get, + template: tpl +}; diff --git a/src/qtiCommonRenderer/renderers/choices/SimpleAssociableChoice.MatchInteraction.js b/src/qtiCommonRenderer/renderers/choices/SimpleAssociableChoice.MatchInteraction.js new file mode 100644 index 00000000..d58346b9 --- /dev/null +++ b/src/qtiCommonRenderer/renderers/choices/SimpleAssociableChoice.MatchInteraction.js @@ -0,0 +1,31 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ + +/** + * @author Sam Sipasseuth + * @author Bertrand Chevrier + */ +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/choices/simpleAssociableChoice.matchInteraction'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; + +export default { + qtiClass: 'simpleAssociableChoice.matchInteraction', + getContainer: containerHelper.get, + template: tpl +}; diff --git a/src/qtiCommonRenderer/renderers/choices/SimpleChoice.ChoiceInteraction.js b/src/qtiCommonRenderer/renderers/choices/SimpleChoice.ChoiceInteraction.js new file mode 100644 index 00000000..c226955e --- /dev/null +++ b/src/qtiCommonRenderer/renderers/choices/SimpleChoice.ChoiceInteraction.js @@ -0,0 +1,34 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ + +/** + * @author Sam Sipasseuth + */ +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/choices/simpleChoice.choiceInteraction'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; + +export default { + qtiClass: 'simpleChoice.choiceInteraction', + getContainer: containerHelper.get, + getData: function(choice, data) { + data.unique = parseInt(data.interaction.attributes.maxChoices) === 1; + return data; + }, + template: tpl +}; diff --git a/src/qtiCommonRenderer/renderers/choices/SimpleChoice.OrderInteraction.js b/src/qtiCommonRenderer/renderers/choices/SimpleChoice.OrderInteraction.js new file mode 100644 index 00000000..633bab95 --- /dev/null +++ b/src/qtiCommonRenderer/renderers/choices/SimpleChoice.OrderInteraction.js @@ -0,0 +1,31 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ + +/** + * @author Sam Sipasseuth + * @author Bertrand Chevrier + */ +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/choices/choice'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; + +export default { + qtiClass: 'simpleChoice.orderInteraction', + getContainer: containerHelper.get, + template: tpl +}; diff --git a/src/qtiCommonRenderer/renderers/config.js b/src/qtiCommonRenderer/renderers/config.js new file mode 100644 index 00000000..88c8edcd --- /dev/null +++ b/src/qtiCommonRenderer/renderers/config.js @@ -0,0 +1,123 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technologies SA; + */ + +import _ from 'lodash'; +import context from 'context'; +import themes from 'ui/themes'; +import assetManagerFactory from 'taoItems/assets/manager'; +import assetStrategies from 'taoItems/assets/strategies'; +import module from 'module'; +import portableAssetStrategy from 'taoQtiItem/portableElementRegistry/assetManager/portableAssetStrategy'; + +var itemThemes = themes.get('items'); +var moduleConfig = module.config(); + +//Create asset manager stack +var assetManager = assetManagerFactory( + [ + { + name: 'theme', + handle: function handleTheme(url) { + if ( + itemThemes && + url.path && + (url.path === itemThemes.base || _.contains(_.pluck(itemThemes.available, 'path'), url.path)) + ) { + return context.root_url + url.toString(); + } + } + }, + assetStrategies.taomedia, + assetStrategies.external, + assetStrategies.base64, + assetStrategies.itemCssNoCache, + assetStrategies.baseUrl, + portableAssetStrategy + ], + { baseUrl: '' } +); //baseUrl is not predefined in the config, but should be set upon renderer instantiating + +//renderers locations +var locations = { + assessmentItem: 'taoQtiItem/qtiCommonRenderer/renderers/Item', + _container: 'taoQtiItem/qtiCommonRenderer/renderers/Container', + _simpleFeedbackRule: false, + _tooltip: 'taoQtiItem/qtiCommonRenderer/renderers/Tooltip', + stylesheet: 'taoQtiItem/qtiCommonRenderer/renderers/Stylesheet', + outcomeDeclaration: false, + responseDeclaration: false, + responseProcessing: false, + img: 'taoQtiItem/qtiCommonRenderer/renderers/Img', + math: 'taoQtiItem/qtiCommonRenderer/renderers/Math', + object: 'taoQtiItem/qtiCommonRenderer/renderers/Object', + table: 'taoQtiItem/qtiCommonRenderer/renderers/Table', + printedVariable: 'taoQtiItem/qtiCommonRenderer/renderers/PrintedVariable', + rubricBlock: 'taoQtiItem/qtiCommonRenderer/renderers/RubricBlock', + modalFeedback: 'taoQtiItem/qtiCommonRenderer/renderers/ModalFeedback', + prompt: 'taoQtiItem/qtiCommonRenderer/renderers/interactions/Prompt', + choiceInteraction: 'taoQtiItem/qtiCommonRenderer/renderers/interactions/ChoiceInteraction', + extendedTextInteraction: 'taoQtiItem/qtiCommonRenderer/renderers/interactions/ExtendedTextInteraction', + orderInteraction: 'taoQtiItem/qtiCommonRenderer/renderers/interactions/OrderInteraction', + associateInteraction: 'taoQtiItem/qtiCommonRenderer/renderers/interactions/AssociateInteraction', + matchInteraction: 'taoQtiItem/qtiCommonRenderer/renderers/interactions/MatchInteraction', + textEntryInteraction: 'taoQtiItem/qtiCommonRenderer/renderers/interactions/TextEntryInteraction', + sliderInteraction: 'taoQtiItem/qtiCommonRenderer/renderers/interactions/SliderInteraction', + inlineChoiceInteraction: 'taoQtiItem/qtiCommonRenderer/renderers/interactions/InlineChoiceInteraction', + 'simpleChoice.choiceInteraction': 'taoQtiItem/qtiCommonRenderer/renderers/choices/SimpleChoice.ChoiceInteraction', + 'simpleChoice.orderInteraction': 'taoQtiItem/qtiCommonRenderer/renderers/choices/SimpleChoice.OrderInteraction', + hottext: 'taoQtiItem/qtiCommonRenderer/renderers/choices/Hottext', + gap: 'taoQtiItem/qtiCommonRenderer/renderers/choices/Gap', + gapText: 'taoQtiItem/qtiCommonRenderer/renderers/choices/GapText', + 'simpleAssociableChoice.matchInteraction': + 'taoQtiItem/qtiCommonRenderer/renderers/choices/SimpleAssociableChoice.MatchInteraction', + 'simpleAssociableChoice.associateInteraction': + 'taoQtiItem/qtiCommonRenderer/renderers/choices/SimpleAssociableChoice.AssociateInteraction', + inlineChoice: 'taoQtiItem/qtiCommonRenderer/renderers/choices/InlineChoice', + hottextInteraction: 'taoQtiItem/qtiCommonRenderer/renderers/interactions/HottextInteraction', + hotspotInteraction: 'taoQtiItem/qtiCommonRenderer/renderers/interactions/HotspotInteraction', + hotspotChoice: false, + gapMatchInteraction: 'taoQtiItem/qtiCommonRenderer/renderers/interactions/GapMatchInteraction', + selectPointInteraction: 'taoQtiItem/qtiCommonRenderer/renderers/interactions/SelectPointInteraction', + graphicOrderInteraction: 'taoQtiItem/qtiCommonRenderer/renderers/interactions/GraphicOrderInteraction', + mediaInteraction: 'taoQtiItem/qtiCommonRenderer/renderers/interactions/MediaInteraction', + uploadInteraction: 'taoQtiItem/qtiCommonRenderer/renderers/interactions/UploadInteraction', + graphicGapMatchInteraction: 'taoQtiItem/qtiCommonRenderer/renderers/interactions/GraphicGapMatchInteraction', + gapImg: 'taoQtiItem/qtiCommonRenderer/renderers/choices/GapImg', + graphicAssociateInteraction: 'taoQtiItem/qtiCommonRenderer/renderers/interactions/GraphicAssociateInteraction', + associableHotspot: false, + customInteraction: 'taoQtiItem/qtiCommonRenderer/renderers/interactions/PortableCustomInteraction', + infoControl: 'taoQtiItem/qtiCommonRenderer/renderers/PortableInfoControl', + include: 'taoQtiItem/qtiCommonRenderer/renderers/Include', + endAttemptInteraction: 'taoQtiItem/qtiCommonRenderer/renderers/interactions/EndAttemptInteraction' +}; + +export default { + name: 'commonRenderer', + locations: locations, + options: { + assetManager: assetManager, + themes: itemThemes, + enableDragAndDrop: { + associate: !!moduleConfig.associateDragAndDrop, + gapMatch: !!moduleConfig.gapMatchDragAndDrop, + graphicGapMatch: !!moduleConfig.graphicGapMatchDragAndDrop, + order: !!moduleConfig.orderDragAndDrop + }, + messages: moduleConfig.messages + } +}; diff --git a/src/qtiCommonRenderer/renderers/graphic-style.json b/src/qtiCommonRenderer/renderers/graphic-style.json new file mode 100644 index 00000000..4da47f11 --- /dev/null +++ b/src/qtiCommonRenderer/renderers/graphic-style.json @@ -0,0 +1,156 @@ +{ + "basic": { + "stroke": "#8D949E", + "stroke-width": 2, + "stroke-dasharray": "", + "stroke-linejoin": "round", + "fill": "#cccccc", + "fill-opacity": 0.5, + "cursor": "pointer" + }, + "hover": { + "stroke": "#3E7DA7", + "fill": "#0E5D91", + "fill-opacity": 0.3 + }, + "selectable": { + "stroke-dasharray": "-", + "stroke": "#3E7DA7", + "fill": "#cccccc", + "fill-opacity": 0.5 + }, + "active": { + "stroke": "#3E7DA7", + "stroke-dasharray": "", + "fill": "#0E5D91", + "fill-opacity": 0.5 + }, + "error": { + "stroke": "#C74155", + "stroke-dasharray": "", + "fill-opacity": 0.5, + "fill": "#661728" + }, + "success": { + "stroke": "#C74155", + "stroke-dasharray": "", + "fill": "#0E914B", + "fill-opacity": 0.5 + }, + "layer": { + "fill": "#ffffff", + "opacity": 0, + "cursor": "pointer" + }, + "creator": { + "fill-opacity": 0.5, + "stroke": "#3E7DA7", + "stroke-dasharray": "", + "fill": "#0E5D91", + "cursor": "pointer" + }, + "imageset-rect-stroke": { + "fill": "#ffffff", + "stroke": "#666666", + "stroke-width": 1, + "stroke-linejoin": "round", + "cursor": "pointer" + }, + "imageset-rect-no-stroke": { + "fill": "#ffffff", + "stroke": "#ffffff", + "stroke-width": 2, + "stroke-linejoin": "round", + "cursor": "pointer" + }, + "imageset-img": { + "cursor": "pointer" + }, + "order-text": { + "fill": "#ffffff", + "stroke": "#000000", + "stroke-width": 0.7, + "font-family": "sans-serif", + "font-weight": "bold", + "font-size": 22, + "cursor": "pointer" + }, + "score-text-default": { + "stroke": "#444444", + "stroke-width": 0.5, + "font-family": "sans-serif", + "font-weight": "normal", + "font-size": 20, + "cursor": "pointer" + }, + "score-text": { + "stroke": "#000000", + "stroke-width": 0.5, + "font-family": "sans-serif", + "font-weight": "normal", + "font-size": 20, + "cursor": "pointer" + }, + "small-text": { + "stroke": "#000000", + "stroke-width": 0.5, + "font-family": "sans-serif", + "font-weight": "normal", + "font-size": 16, + "cursor": "pointer" + }, + "layer-pos-text": { + "stroke": "#333", + "stroke-width": 0.5, + "font-family": "sans-serif", + "font-weight": "normal", + "font-size": 14 + }, + "target": { + "path": "m 18,8.4143672 -1.882582,0 C 15.801891,4.9747852 13.071059,2.2344961 9.63508,1.9026738 L 9.63508,0 8.2305176,0 l 0,1.9026387 C 4.7947148,2.2343027 2.0637246,4.9746621 1.7481973,8.4143672 l -1.7481973,0 0,1.4045625 1.754877,0 c 0.3460429,3.4066753 3.0632871,6.1119843 6.4756406,6.4413813 l 0,1.739689 1.4045624,0 0,-1.739725 c 3.412547,-0.329537 6.129633,-3.034793 6.475641,-6.4413453 l 1.889279,0 z m -8.36492,6.5188648 0,-4.064673 -1.4045624,0 0,4.063882 C 5.5511016,14.612555 3.4232695,12.494619 3.0864551,9.8189297 l 4.0449512,0 0,-1.4045625 -4.0546368,0 C 3.3788672,5.6984941 5.5228887,3.5393379 8.2305176,3.2161113 l 0,3.9153125 1.4045624,0 0,-3.9160859 c 2.711162,0.3203965 4.858576,2.4808887 5.160955,5.1990293 l -3.927441,0 0,1.4045625 3.917773,0 C 14.449289,12.496957 12.318363,14.616158 9.63508,14.933232 z", + "fill": "#0E914B", + "width": 1, + "stroke-width": 0, + "cursor": "pointer" + }, + "target-hover": { + "fill": "#3E7DA7", + "fill-opacity": 1 + }, + "target-success": { + "fill": "#0E914B", + "fill-opacity": 1 + }, + "assoc": { + "stroke-width": 2, + "stroke-linecap": "round", + "cursor": "pointer" + }, + "assoc-layer": { + "stroke-width": 12, + "cursor": "pointer", + "stroke-opacity": 0 + }, + "assoc-bullet": { + "fill": "#000000" + }, + "close": { + "path": "m 8.9997236,18.000001 c -4.9703918,0 -8.99972284217367,-4.02901 -8.99972284217367,-9 C 7.5782633e-7,4.029011 4.0293108,9.8531742e-7 8.9997236,9.8531742e-7 13.970691,9.8531742e-7 18.000001,4.029011 18.000001,9.000001 c 0,4.97099 -4.02931,9 -9.0002774,9 z m 0.0045,-16.37151 c -4.06191,0 -7.35492,3.29635 -7.35492,7.36251 0,4.06562 3.292989,7.36255 7.35492,7.36255 4.0630384,0 7.3554334,-3.29693 7.3554334,-7.36255 0,-4.06614 -3.292969,-7.36251 -7.3554334,-7.36251 v 0 z m 3.1314894,9.31167 -1.953823,-1.94014 1.953843,-1.94018 c 0.08964,-0.089 0.134622,-0.19099 0.135073,-0.30584 4.31e-4,-0.11488 -0.04383,-0.21712 -0.132838,-0.30681 l -0.54267,-0.54685 c -0.08898,-0.0897 -0.190903,-0.13473 -0.305719,-0.13514 -0.114837,-4.4e-4 -0.217064,0.0439 -0.306703,0.1329 l -1.9623724,1.94865 -1.962395,-1.94865 c -0.08964,-0.089 -0.191845,-0.13336 -0.306702,-0.1329 -0.114837,4.3e-4 -0.216736,0.0455 -0.305719,0.13514 l -0.54265,0.54685 c -0.08902,0.0897 -0.133269,0.19193 -0.132838,0.30681 4.3e-4,0.11485 0.04543,0.21681 0.135073,0.30584 l 1.953823,1.94018 -1.953782,1.94014 c -0.0896,0.089 -0.134684,0.19094 -0.135114,0.3058 -4.31e-4,0.11486 0.04386,0.21716 0.132838,0.30681 l 0.542671,0.54687 c 0.08896,0.0897 0.190923,0.13467 0.305718,0.13516 0.114857,3.9e-4 0.217105,-0.0439 0.306724,-0.13288 l 1.962332,-1.94863 1.9623324,1.94863 c 0.08962,0.089 0.191886,0.13323 0.306744,0.13288 0.114836,-4.5e-4 0.216736,-0.0455 0.305698,-0.13516 l 0.542691,-0.54687 c 0.089,-0.0897 0.133227,-0.19193 0.132838,-0.30681 -3.9e-4,-0.1149 -0.0455,-0.21683 -0.135073,-0.3058 z", + "fill": "#0E5D91", + "width": 1, + "opacity": 0, + "stroke-width": 0, + "cursor": "pointer" + }, + "close-bg": { + "fill": "#ffffff", + "stroke": "none", + "cursor": "pointer", + "opacity": 0 + }, + "touch-circle": { + "fill": "none", + "stroke": "#3E7DA7", + "stroke-width": 2 + } +} diff --git a/src/qtiCommonRenderer/renderers/interactions/AssociateInteraction.js b/src/qtiCommonRenderer/renderers/interactions/AssociateInteraction.js new file mode 100644 index 00000000..7ca51c4b --- /dev/null +++ b/src/qtiCommonRenderer/renderers/interactions/AssociateInteraction.js @@ -0,0 +1,813 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014-2019 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ + +/** + * @author Sam Sipasseuth + * @author Bertrand Chevrier + */ +import $ from 'jquery'; +import _ from 'lodash'; +import __ from 'i18n'; +import Promise from 'core/promise'; +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/associateInteraction'; +import pairTpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/associateInteraction.pair'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; +import instructionMgr from 'taoQtiItem/qtiCommonRenderer/helpers/instructions/instructionManager'; +import pciResponse from 'taoQtiItem/qtiCommonRenderer/helpers/PciResponse'; +import sizeAdapter from 'taoQtiItem/qtiCommonRenderer/helpers/sizeAdapter'; +import interact from 'interact'; +import interactUtils from 'ui/interactUtils'; + +var setChoice = function(interaction, $choice, $target) { + var $container = containerHelper.get(interaction); + var choiceSerial = $choice.data('serial'); + var usage = $choice.data('usage') || 0; + var choice = interaction.getChoice(choiceSerial); + + if (!choiceSerial) { + throw 'empty choice serial'; + } + + //to track number of times a choice is used in a pair + usage++; + $choice.data('usage', usage); + + var _setChoice = function() { + $target + .data('serial', choiceSerial) + .html($choice.html()) + .addClass('filled'); + + if (!interaction.responseMappingMode && choice.attr('matchMax') && usage >= choice.attr('matchMax')) { + $choice.addClass('deactivated'); + } + }; + + if ($target.siblings('div').hasClass('filled')) { + var $resultArea = $('.result-area', $container), + $pair = $target.parent(), + thisPairSerial = [$target.siblings('div').data('serial'), choiceSerial], + $otherRepeatedPair = $(); + + //check if it is not a repeating association! + $resultArea + .children() + .not($pair) + .each(function() { + var $otherPair = $(this).children('.filled'); + if ($otherPair.length === 2) { + var otherPairSerial = [$($otherPair[0]).data('serial'), $($otherPair[1]).data('serial')]; + if (_.intersection(thisPairSerial, otherPairSerial).length === 2) { + $otherRepeatedPair = $otherPair; + return false; + } + } + }); + + if ($otherRepeatedPair.length === 0) { + //no repeated pair, so allow the choice to be set: + _setChoice(); + + //trigger pair made event + containerHelper.triggerResponseChangeEvent(interaction, { + type: 'added', + $pair: $pair, + choices: thisPairSerial + }); + + instructionMgr.validateInstructions(interaction, { choice: $choice, target: $target }); + + if (interaction.responseMappingMode || parseInt(interaction.attr('maxAssociations')) === 0) { + $pair.removeClass('incomplete-pair'); + + //append new pair option? + if (!$resultArea.children('.incomplete-pair').length) { + $resultArea.append(pairTpl({ empty: true })); + $resultArea.children('.incomplete-pair').fadeIn(600, function() { + $(this).show(); + }); + } + } + } else { + //repeating pair: show it: + + //@todo add a notification message here in warning + $otherRepeatedPair.css('border', '1px solid orange'); + $target.html(__('identical pair already exists')).css({ + color: 'orange', + border: '1px solid orange' + }); + setTimeout(function() { + $otherRepeatedPair.removeAttr('style'); + $target.empty().css({ color: '', border: '' }); + }, 2000); + } + } else { + _setChoice(); + } +}; + +var unsetChoice = function(interaction, $filledChoice, animate, triggerChange) { + var $container = containerHelper.get(interaction); + var choiceSerial = $filledChoice.data('serial'); + var $choice = $container.find('.choice-area [data-serial=' + choiceSerial + ']'); + var usage = $choice.data('usage') || 0; + var $parent = $filledChoice.parent(); + var $sibling = $container.find( + '.choice-area [data-serial=' + $filledChoice.siblings('.target').data('serial') + ']' + ); + + //decrease the use for this choice + usage--; + + $choice.data('usage', usage).removeClass('deactivated'); + + $filledChoice + .removeClass('filled') + .removeData('serial') + .empty(); + + if (!interaction.swapping) { + if (triggerChange !== false) { + //a pair with one single element is not valid, so consider the response to be modified: + containerHelper.triggerResponseChangeEvent(interaction, { + type: 'removed', + $pair: $filledChoice.parent() + }); + instructionMgr.validateInstructions(interaction, { choice: $choice }); + } + + //if we are to remove the sibling too, update its usage: + $sibling.data('usage', $sibling.data('usage') - 1).removeClass('deactivated'); + + //completely empty pair: + if ( + !$choice.siblings('div').hasClass('filled') && + (parseInt(interaction.attr('maxAssociations')) === 0 || interaction.responseMappingMode) + ) { + //shall we remove it? + if (!$parent.hasClass('incomplete-pair')) { + if (animate) { + $parent.addClass('removing').fadeOut(500, function() { + $(this).remove(); + }); + } else { + $parent.remove(); + } + } + } + } +}; + +var getChoice = function(interaction, identifier) { + var $container = containerHelper.get(interaction); + + //warning: do not use selector data-identifier=identifier because data-identifier may change dynamically + var choice = interaction.getChoiceByIdentifier(identifier); + if (!choice) { + throw new Error('cannot find a choice with the identifier : ' + identifier); + } + return $('.choice-area [data-serial=' + choice.getSerial() + ']', $container); +}; + +var renderEmptyPairs = function(interaction) { + var $container = containerHelper.get(interaction); + var max = parseInt(interaction.attr('maxAssociations')); + var $resultArea = $('.result-area', $container); + + if (interaction.responseMappingMode || max === 0) { + $resultArea.append(pairTpl({ empty: true })); + $resultArea.children('.incomplete-pair').show(); + } else { + for (var i = 0; i < max; i++) { + $resultArea.append(pairTpl()); + } + } +}; + +/** + * Init rendering, called after template injected into the DOM + * All options are listed in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10291 + * + * @param {object} interaction + */ +var render = function(interaction) { + var self = this; + + return new Promise(function(resolve, reject) { + var $container = containerHelper.get(interaction); + var $choiceArea = $container.find('.choice-area'); + var $resultArea = $container.find('.result-area'); + + var $activeChoice = null; + + var isDragAndDropEnabled; + var dragOptions; + var dropOptions; + var scaleX, scaleY; + + var $bin = $('', { class: 'icon-undo remove-choice', title: __('remove') }); + + var choiceSelector = $choiceArea.selector + ' >li'; + var resultSelector = $resultArea.selector + ' >li>div'; + var binSelector = $container.selector + ' .remove-choice'; + + var _getChoice = function(serial) { + return $choiceArea.find('[data-serial=' + serial + ']'); + }; + + /** + * @todo Tried to store $resultArea.find[...] in a variable but this fails + * @param $choice + * @param $target + * @private + */ + var _setChoice = function($choice, $target) { + setChoice(interaction, $choice, $target); + sizeAdapter.adaptSize( + $('.result-area .target, .choice-area .qti-choice', containerHelper.get(interaction)) + ); + }; + + var _resetSelection = function() { + if ($activeChoice) { + $resultArea.find('.remove-choice').remove(); + $activeChoice.removeClass('active'); + $container.find('.empty').removeClass('empty'); + $activeChoice = null; + } + }; + + var _unsetChoice = function($choice) { + unsetChoice(interaction, $choice, true); + sizeAdapter.adaptSize( + $('.result-area .target, .choice-area .qti-choice', containerHelper.get(interaction)) + ); + }; + + var _isInsertionMode = function() { + return $activeChoice && $activeChoice.data('identifier'); + }; + + var _isModeEditing = function() { + return $activeChoice && !$activeChoice.data('identifier'); + }; + + var _handleChoiceActivate = function($target) { + if ($target.hasClass('deactivated')) { + return; + } + + if (_isModeEditing()) { + //swapping: + interaction.swapping = true; + _unsetChoice($activeChoice); + _setChoice($target, $activeChoice); + _resetSelection(); + interaction.swapping = false; + } else { + if ($target.hasClass('active')) { + _resetSelection(); + } else { + _activateChoice($target); + } + } + }; + + var _activateChoice = function($choice) { + _resetSelection(); + $activeChoice = $choice; + $choice.addClass('active'); + $resultArea.find('>li>.target').addClass('empty'); + }; + + var _handleResultActivate = function($target) { + var choiceSerial, + targetSerial = $target.data('serial'); + + if (_isInsertionMode()) { + choiceSerial = $activeChoice.data('serial'); + + if (targetSerial !== choiceSerial) { + if ($target.hasClass('filled')) { + interaction.swapping = true; //hack to prevent deleting empty pair in infinite association mode + } + //set choices: + if (targetSerial) { + _unsetChoice($target); + } + _setChoice($activeChoice, $target); + + //always reset swapping mode after the choice is set + interaction.swapping = false; + } + + _resetSelection(); + } else if (_isModeEditing()) { + choiceSerial = $activeChoice.data('serial'); + + if (targetSerial !== choiceSerial) { + if ($target.hasClass('filled') || $activeChoice.siblings('div')[0] === $target[0]) { + interaction.swapping = true; //hack to prevent deleting empty pair in infinite association mode + } + + _unsetChoice($activeChoice); + if (targetSerial) { + //swapping: + _unsetChoice($target); + _setChoice(_getChoice(targetSerial), $activeChoice); + } + _setChoice(_getChoice(choiceSerial), $target); + + //always reset swapping mode after the choice is set + interaction.swapping = false; + } + + _resetSelection(); + } else if (targetSerial) { + _activateResult($target); + $target.append($bin); + } + }; + + var _activateResult = function($target) { + var targetSerial = $target.data('serial'); + + $activeChoice = $target; + $activeChoice.addClass('active'); + + $resultArea + .find('>li>.target') + .filter(function() { + return $(this).data('serial') !== targetSerial; + }) + .addClass('empty'); + + $choiceArea + .find('>li:not(.deactivated)') + .filter(function() { + return $(this).data('serial') !== targetSerial; + }) + .addClass('empty'); + }; + + // Point & click handlers + + interact($container.selector).on('tap', function(e) { + //if tts component is loaded and click-to-speak function is activated - we should prevent this listener to go further + if ( + $(e.currentTarget) + .closest('.qti-item') + .hasClass('prevent-click-handler') + ) { + return; + } + + _resetSelection(); + }); + + interact($choiceArea.selector + ' >li').on('tap', function(e) { + var $target = $(e.currentTarget); + + //if tts component is loaded and click-to-speak function is activated - we should prevent this listener to go further + if ($target.closest('.qti-item').hasClass('prevent-click-handler')) { + return; + } + + e.stopPropagation(); + _handleChoiceActivate($target); + e.preventDefault(); + }); + + interact($resultArea.selector + ' >li>div').on('tap', function(e) { + var $target = $(e.currentTarget); + + //if tts component is loaded and click-to-speak function is activated - we should prevent this listener to go further + if ($target.closest('.qti-item').hasClass('prevent-click-handler')) { + return; + } + + e.stopPropagation(); + _handleResultActivate($target); + e.preventDefault(); + }); + + interact(binSelector).on('tap', function(e) { + //if tts component is loaded and click-to-speak function is activated - we should prevent this listener to go further + if ( + $(e.currentTarget) + .closest('.qti-item') + .hasClass('prevent-click-handler') + ) { + return; + } + + e.stopPropagation(); + _unsetChoice($activeChoice); + _resetSelection(); + e.preventDefault(); + }); + + if (!interaction.responseMappingMode) { + _setInstructions(interaction); + } + + // Drag & drop handlers + + if (self.getOption && self.getOption('enableDragAndDrop') && self.getOption('enableDragAndDrop').associate) { + isDragAndDropEnabled = self.getOption('enableDragAndDrop').associate; + } + + function _iFrameDragFix(draggableSelector, target) { + interactUtils.iFrameDragFixOn(function() { + var $activeDrop = $(resultSelector + '.dropzone'); + if ($activeDrop.length) { + interact(resultSelector).fire({ + type: 'drop', + target: $activeDrop.eq(0), + relatedTarget: target + }); + } + $activeDrop = $(choiceSelector + '.dropzone'); + if ($activeDrop.length) { + interact(choiceSelector + '.empty').fire({ + type: 'drop', + target: $activeDrop.eq(0), + relatedTarget: target + }); + } + interact(draggableSelector).fire({ + type: 'dragend', + target: target + }); + }); + } + + if (isDragAndDropEnabled) { + dragOptions = { + inertia: false, + autoScroll: true, + restrict: { + restriction: '.qti-interaction', + endOnly: false, + elementRect: { top: 0, left: 0, bottom: 1, right: 1 } + } + }; + + // makes choices draggables + interact(choiceSelector + ':not(.deactivated)') + .draggable( + _.defaults( + { + onstart: function(e) { + var $target = $(e.target); + var scale; + $target.addClass('dragged'); + _activateChoice($target); + _iFrameDragFix(choiceSelector + ':not(.deactivated)', e.target); + scale = interactUtils.calculateScale(e.target); + scaleX = scale[0]; + scaleY = scale[1]; + }, + onmove: function(e) { + interactUtils.moveElement(e.target, e.dx / scaleX, e.dy / scaleY); + }, + onend: function(e) { + var $target = $(e.target); + $target.removeClass('dragged'); + _resetSelection(); + + interactUtils.restoreOriginalPosition($target); + interactUtils.iFrameDragFixOff(); + } + }, + dragOptions + ) + ) + .styleCursor(false); + + // makes results draggables + interact(resultSelector + '.filled') + .draggable( + _.defaults( + { + onstart: function(e) { + var $target = $(e.target); + var scale; + $target.addClass('dragged'); + _resetSelection(); + _activateResult($target); + _iFrameDragFix(resultSelector + '.filled', e.target); + scale = interactUtils.calculateScale(e.target); + scaleX = scale[0]; + scaleY = scale[1]; + }, + onmove: function(e) { + interactUtils.moveElement(e.target, e.dx / scaleX, e.dy / scaleY); + }, + onend: function(e) { + var $target = $(e.target); + $target.removeClass('dragged'); + + interactUtils.restoreOriginalPosition($target); + + if ($activeChoice) { + _unsetChoice($activeChoice); + } + _resetSelection(); + + interactUtils.iFrameDragFixOff(); + } + }, + dragOptions + ) + ) + .styleCursor(false); + + dropOptions = { + overlap: 0.15, + ondragenter: function(e) { + $(e.target).addClass('dropzone'); + $(e.relatedTarget).addClass('droppable'); + }, + ondragleave: function(e) { + $(e.target).removeClass('dropzone'); + $(e.relatedTarget).removeClass('droppable'); + } + }; + + // makes hotspots droppables + interact(resultSelector).dropzone( + _.defaults( + { + ondrop: function(e) { + this.ondragleave(e); + _handleResultActivate($(e.target)); + } + }, + dropOptions + ) + ); + + // makes available choices droppables + interact(choiceSelector + '.empty').dropzone( + _.defaults( + { + ondrop: function(e) { + this.ondragleave(e); + _handleChoiceActivate($(e.target)); + } + }, + dropOptions + ) + ); + } + + // interaction init + + renderEmptyPairs(interaction); + + sizeAdapter.adaptSize($('.result-area .target, .choice-area .qti-choice', $container)); + + resolve(); + }); +}; + +var _setInstructions = function(interaction) { + var min = parseInt(interaction.attr('minAssociations'), 10), + max = parseInt(interaction.attr('maxAssociations'), 10); + + //infinite association: + if (min === 0) { + if (max === 0) { + instructionMgr.appendInstruction(interaction, __('You may make as many association pairs as you want.')); + } + } else { + if (max === 0) { + instructionMgr.appendInstruction(interaction, __('The maximum number of association is unlimited.')); + } + //the max value is implicit since the appropriate number of empty pairs have already been created + var msg = __('You need to make') + ' '; + msg += min > 1 ? __('at least') + ' ' + min + ' ' + __('association pairs') : __('one association pair'); + instructionMgr.appendInstruction(interaction, msg, function() { + if (_getRawResponse(interaction).length >= min) { + this.setLevel('success'); + } else { + this.reset(); + } + }); + } +}; + +var resetResponse = function(interaction) { + var $container = containerHelper.get(interaction); + + //destroy selected choice: + $container.find('.result-area .active').each(function() { + interactUtils.tapOn(this); + }); + + $('.result-area>li>div', $container).each(function() { + unsetChoice(interaction, $(this), false, false); + }); + + containerHelper.triggerResponseChangeEvent(interaction); + instructionMgr.validateInstructions(interaction); +}; + +var _setPairs = function(interaction, pairs) { + var $container = containerHelper.get(interaction); + var addedPairs = 0; + var $emptyPair = $('.result-area>li:first', $container); + if (pairs && interaction.getResponseDeclaration().attr('cardinality') === 'single' && pairs.length) { + pairs = [pairs]; + } + _.each(pairs, function(pair) { + if ($emptyPair.length) { + var $divs = $emptyPair.children('div'); + setChoice(interaction, getChoice(interaction, pair[0]), $($divs[0])); + setChoice(interaction, getChoice(interaction, pair[1]), $($divs[1])); + addedPairs++; + $emptyPair = $emptyPair.next('li'); + } else { + //the number of pairs exceeds the maximum allowed pairs: break; + return false; + } + }); + + return addedPairs; +}; + +/** + * Set the response to the rendered interaction. + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10291 + * + * @param {object} interaction + * @param {object} response + */ +var setResponse = function(interaction, response) { + _setPairs(interaction, pciResponse.unserialize(response, interaction)); +}; + +var _getRawResponse = function(interaction) { + var response = []; + var $container = containerHelper.get(interaction); + $('.result-area>li', $container).each(function() { + var pair = []; + $(this) + .find('div') + .each(function() { + var serial = $(this).data('serial'); + if (serial) { + pair.push(interaction.getChoice(serial).id()); + } + }); + if (pair.length === 2) { + response.push(pair); + } + }); + return response; +}; + +/** + * Return the response of the rendered interaction + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10291 + * + * @param {object} interaction + * @returns {object} + */ +var getResponse = function(interaction) { + return pciResponse.serialize(_getRawResponse(interaction), interaction); +}; + +/** + * Destroy the interaction by leaving the DOM exactly in the same state it was before loading the interaction. + * @param {Object} interaction - the interaction + */ +var destroy = function(interaction) { + var $container = containerHelper.get(interaction); + + //remove event + interact($container.selector).unset(); + interact($container.find('.choice-area').selector + ' >li').unset(); + interact($container.find('.result-area').selector + ' >li>div').unset(); + interact($container.find('.remove-choice').selector).unset(); + + //remove instructions + instructionMgr.removeInstructions(interaction); + + $('.result-area', $container).empty(); + + //remove all references to a cache container + containerHelper.reset(interaction); +}; + +/** + * Set the interaction state. It could be done anytime with any state. + * + * @param {Object} interaction - the interaction instance + * @param {Object} state - the interaction state + */ +var setState = function setState(interaction, state) { + var $container; + + if (_.isObject(state)) { + if (state.response) { + interaction.resetResponse(); + interaction.setResponse(state.response); + } + + //restore order of previously shuffled choices + if (_.isArray(state.order) && state.order.length === _.size(interaction.getChoices())) { + $container = containerHelper.get(interaction); + + $('.choice-area .qti-choice', $container) + .sort(function(a, b) { + var aIndex = _.indexOf(state.order, $(a).data('identifier')); + var bIndex = _.indexOf(state.order, $(b).data('identifier')); + if (aIndex > bIndex) { + return 1; + } + if (aIndex < bIndex) { + return -1; + } + return 0; + }) + .detach() + .appendTo($('.choice-area', $container)); + } + } +}; + +/** + * Get the interaction state. + * + * @param {Object} interaction - the interaction instance + * @returns {Object} the interaction current state + */ +var getState = function getState(interaction) { + var $container; + var state = {}; + var response = interaction.getResponse(); + + if (response) { + state.response = response; + } + + //we store also the choice order if shuffled + if (interaction.attr('shuffle') === true) { + $container = containerHelper.get(interaction); + + state.order = []; + $('.choice-area .qti-choice', $container).each(function() { + state.order.push($(this).data('identifier')); + }); + } + return state; +}; + +/** + * Expose the common renderer for the associate interaction + * @exports qtiCommonRenderer/renderers/interactions/AssociateInteraction + */ +export default { + qtiClass: 'associateInteraction', + template: tpl, + render: render, + getContainer: containerHelper.get, + setResponse: setResponse, + getResponse: getResponse, + resetResponse: resetResponse, + destroy: destroy, + setState: setState, + getState: getState, + + renderEmptyPairs: renderEmptyPairs +}; diff --git a/src/qtiCommonRenderer/renderers/interactions/ChoiceInteraction.js b/src/qtiCommonRenderer/renderers/interactions/ChoiceInteraction.js new file mode 100644 index 00000000..12631979 --- /dev/null +++ b/src/qtiCommonRenderer/renderers/interactions/ChoiceInteraction.js @@ -0,0 +1,495 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + * + */ + +/** + * @author Sam Sipasseuth + * @author Bertrand Chevrier + */ +import _ from 'lodash'; +import $ from 'jquery'; +import __ from 'i18n'; +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/choiceInteraction'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; +import instructionMgr from 'taoQtiItem/qtiCommonRenderer/helpers/instructions/instructionManager'; +import pciResponse from 'taoQtiItem/qtiCommonRenderer/helpers/PciResponse'; +import sizeAdapter from 'taoQtiItem/qtiCommonRenderer/helpers/sizeAdapter'; + +var KEY_CODE_SPACE = 32; +var KEY_CODE_ENTER = 13; +var KEY_CODE_LEFT = 37; +var KEY_CODE_UP = 38; +var KEY_CODE_RIGHT = 39; +var KEY_CODE_DOWN = 40; + +/** + * Propagate the checked state to the actual input. + * @type {Function} + * @param {jQuery} $choiceBox - list element with the class `.qti-choice` + * @param {Boolean} state + * @private + */ +var _triggerInput = function _triggerInput($choiceBox, state) { + var $input = $choiceBox + .find('input:radio,input:checkbox') + .not('[disabled]') + .not('.disabled'); + var $choiceBoxes = $choiceBox.add($choiceBox.siblings()); + + if (!$input.length) { + return; + } + + if (!_.isBoolean(state)) { + state = !$input.prop('checked'); + } + + $input.prop('checked', state); + $input.trigger('change'); + + $choiceBoxes.removeClass('user-selected'); + $choiceBoxes + .find('input:checked') + .not('[disabled]') + .not('.disabled') + .parents('.qti-choice') + .addClass('user-selected'); +}; + +/** + * 'pseudo-label' is technically a div that behaves like a label. + * This allows the usage of block elements inside the fake label + * + * @private + * @param {Object} interaction - the interaction instance + * @param {jQueryElement} $container + */ +var _pseudoLabel = function _pseudoLabel(interaction, $container) { + var inputSelector = + '.qti-choice input:radio:not([disabled]):not(.disabled), .qti-choice input:checkbox:not([disabled]):not(.disabled)'; + $container.off('.commonRenderer'); + + $container + .on('keydown.commonRenderer.keyNavigation', inputSelector, function(e) { + var $qtiChoice = $(this).closest('.qti-choice'); + var keyCode = e.keyCode ? e.keyCode : e.charCode; + + if (keyCode === KEY_CODE_UP || keyCode === KEY_CODE_LEFT) { + e.preventDefault(); + e.stopPropagation(); + $qtiChoice + .prev('.qti-choice') + .find('input:radio,input:checkbox') + .not('[disabled]') + .not('.disabled') + .focus(); + } else if (keyCode === KEY_CODE_DOWN || keyCode === KEY_CODE_RIGHT) { + e.preventDefault(); + e.stopPropagation(); + $qtiChoice + .next('.qti-choice') + .find('input:radio,input:checkbox') + .not('[disabled]') + .not('.disabled') + .focus(); + } + }) + .on('keyup.commonRenderer.keyNavigation', inputSelector, function(e) { + var keyCode = e.keyCode ? e.keyCode : e.charCode; + + if (keyCode === KEY_CODE_SPACE || keyCode === KEY_CODE_ENTER) { + e.preventDefault(); + e.stopPropagation(); + _triggerInput($(this).closest('.qti-choice')); + } + }); + + $container.on('click.commonRenderer', '.qti-choice', function(e) { + var $choiceBox = $(this); + var state; + var eliminator = e.target.dataset && e.target.dataset.eliminable; + var input = this.querySelector('.real-label > input'); + + // if the click has been triggered by a keyboard check, prevent this listener to cancel this check + if (e.originalEvent && $(e.originalEvent.target).is('input')) { + return; + } + + //if tts component is loaded and click-to-speak function is activated - we should prevent this listener to go further + if ($choiceBox.closest('.qti-item').hasClass('prevent-click-handler')) { + return; + } + + e.preventDefault(); + e.stopPropagation(); //required otherwise any tao scoped, form initialization might prevent it from working + + if (!_.isUndefined(eliminator)) { + state = false; + if (eliminator === 'trigger') { + this.classList.toggle('eliminated'); + } + } + + _triggerInput($choiceBox, state); + + if (this.classList.contains('eliminated')) { + input.setAttribute('disabled', 'disabled'); + } else { + input.removeAttribute('disabled'); + } + + instructionMgr.validateInstructions(interaction, { choice: $choiceBox }); + containerHelper.triggerResponseChangeEvent(interaction); + }); +}; + +/** + * Get the responses from the DOM. + * @private + * @param {Object} interaction - the interaction instance + * @returns {Array} the list of choices identifiers + */ +var _getRawResponse = function _getRawResponse(interaction) { + var values = []; + var $container = containerHelper.get(interaction); + $('.real-label > input[name=response-' + interaction.getSerial() + ']:checked', $container).each(function() { + values.push($(this).val()); + }); + return values; +}; + +/** + * Define the instructions for the interaction + * @private + * @param {Object} interaction - the interaction instance + */ +var _setInstructions = function _setInstructions(interaction) { + var min = interaction.attr('minChoices'), + max = interaction.attr('maxChoices'), + msg, + choiceCount = _.size(interaction.getChoices()), + minInstructionSet = false; + + var highlightInvalidInput = function highlightInvalidInput($choice) { + var $input = $choice.find('.real-label > input'), + $li = $choice.css('color', '#BA122B'), + $icon = $choice + .find('.real-label > span') + .css('color', '#BA122B') + .addClass('cross error'); + var timeout = interaction.data('__instructionTimeout'); + + if (timeout) { + clearTimeout(timeout); + } + timeout = setTimeout(function() { + $input.prop('checked', false); + $li.removeAttr('style'); + $icon.removeAttr('style').removeClass('cross'); + $li.toggleClass('user-selected', false); + containerHelper.triggerResponseChangeEvent(interaction); + }, 150); + interaction.data('__instructionTimeout', timeout); + }; + + //if maxChoice = 1, use the radio group behaviour + //if maxChoice = 0, infinite choice possible + if (max > 1 && max < choiceCount) { + if (max === min) { + minInstructionSet = true; + msg = __('You must select exactly %s choices', max); + instructionMgr.appendInstruction(interaction, msg, function(data) { + if (_getRawResponse(interaction).length >= max) { + this.setLevel('success'); + if (this.checkState('fulfilled')) { + this.update({ + level: 'warning', + message: __('Maximum choices reached'), + timeout: 2000, + start: function() { + if (data && data.choice) { + highlightInvalidInput(data.choice); + } + }, + stop: function() { + this.update({ level: 'success', message: msg }); + } + }); + } + this.setState('fulfilled'); + } else { + this.reset(); + } + }); + } else if (max > min) { + msg = + max === 1 ? __('You can select maximum of 1 choice') : __('You can select maximum of %s choices', max); + instructionMgr.appendInstruction(interaction, msg, function(data) { + if (_getRawResponse(interaction).length >= max) { + this.setMessage(__('Maximum choices reached')); + if (this.checkState('fulfilled')) { + this.update({ + level: 'warning', + timeout: 2000, + start: function() { + if (data && data.choice) { + highlightInvalidInput(data.choice); + } + }, + stop: function() { + this.setLevel('info'); + } + }); + } + this.setState('fulfilled'); + } else { + this.reset(); + } + }); + } + } + + if (!minInstructionSet && min > 0 && min < choiceCount) { + msg = min === 1 ? __('You must select at least 1 choice') : __('You must select at least %s choices', min); + instructionMgr.appendInstruction(interaction, msg, function() { + if (_getRawResponse(interaction).length >= min) { + this.setLevel('success'); + } else { + this.reset(); + } + }); + } +}; + +/** + * Init rendering, called after template injected into the DOM + * All options are listed in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10278 + * + * @param {Object} interaction - the interaction instance + */ +var render = function render(interaction) { + var $container = containerHelper.get(interaction); + + _pseudoLabel(interaction, $container); + + _setInstructions(interaction); + + if (interaction.attr('orientation') === 'horizontal') { + sizeAdapter.adaptSize($('.add-option, .result-area .target, .choice-area .qti-choice', $container)); + } +}; + +/** + * Reset the responses previously set + * + * @param {Object} interaction - the interaction instance + */ +var resetResponse = function resetResponse(interaction) { + var $container = containerHelper.get(interaction); + + $('.real-label > input', $container).prop('checked', false); +}; + +/** + * Set a new response to the rendered interaction. + * Please note that it does not reset previous responses. + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10278 + * + * @param {Object} interaction - the interaction instance + * @param {0bject} response - the PCI formated response + */ +var setResponse = function setResponse(interaction, response) { + var $container = containerHelper.get(interaction); + + try { + _.forEach(pciResponse.unserialize(response, interaction), function(identifier) { + var $input = $container.find('.real-label > input[value="' + identifier + '"]').prop('checked', true); + $input.closest('.qti-choice').toggleClass('user-selected', true); + }); + instructionMgr.validateInstructions(interaction); + } catch (e) { + throw new Error('wrong response format in argument : ' + e); + } +}; + +/** + * Return the response of the rendered interaction + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10278 + * + * @param {Object} interaction - the interaction instance + * @returns {Object} the response formatted in PCI + */ +var getResponse = function getResponse(interaction) { + return pciResponse.serialize(_getRawResponse(interaction), interaction); +}; + +/** + * Check if a choice interaction is choice-eliminable + * + * @param {Object} interaction + * @returns {boolean} + */ +var isEliminable = function isEliminable(interaction) { + return /\beliminable\b/.test(interaction.attr('class')); +}; + +/** + * Set additional data to the template (data that are not really part of the model). + * @param {Object} interaction - the interaction + * @param {Object} [data] - interaction custom data + * @returns {Object} custom data + */ +var getCustomData = function getCustomData(interaction, data) { + var listStyles = (interaction.attr('class') || '').match(/\blist-style-[\w-]+/) || []; + return _.merge(data || {}, { + horizontal: interaction.attr('orientation') === 'horizontal', + listStyle: listStyles.pop(), + eliminable: isEliminable(interaction) + }); +}; + +/** + * Destroy the interaction by leaving the DOM exactly in the same state it was before loading the interaction. + * @param {Object} interaction - the interaction + */ +var destroy = function destroy(interaction) { + var $container = containerHelper.get(interaction); + + var timeout = interaction.data('__instructionTimeout'); + + if (timeout) { + clearTimeout(timeout); + } + + //remove event + $container.off('.commonRenderer'); + $(document).off('.commonRenderer'); + + //remove instructions + instructionMgr.removeInstructions(interaction); + + //remove all references to a cache container + containerHelper.reset(interaction); +}; + +/** + * Set the interaction state. It could be done anytime with any state. + * + * @param {Object} interaction - the interaction instance + * @param {Object} state - the interaction state + */ +var setState = function setState(interaction, state) { + var $container; + + if (_.isObject(state)) { + if (state.response) { + interaction.resetResponse(); + interaction.setResponse(state.response); + } + + $container = containerHelper.get(interaction); + + //restore order of previously shuffled choices + if (_.isArray(state.order) && state.order.length === _.size(interaction.getChoices())) { + $('.qti-simpleChoice', $container) + .sort(function(a, b) { + var aIndex = _.indexOf(state.order, $(a).data('identifier')); + var bIndex = _.indexOf(state.order, $(b).data('identifier')); + if (aIndex > bIndex) { + return 1; + } + if (aIndex < bIndex) { + return -1; + } + return 0; + }) + .detach() + .appendTo($('.choice-area', $container)); + } + + //restore eliminated choices + if (isEliminable(interaction) && _.isArray(state.eliminated) && state.eliminated.length) { + _.forEach(state.eliminated, function(identifier) { + $container.find('.qti-simpleChoice[data-identifier=' + identifier + ']').addClass('eliminated'); + }); + } + } +}; + +/** + * Get the interaction state. + * + * @param {Object} interaction - the interaction instance + * @returns {Object} the interaction current state + */ +var getState = function getState(interaction) { + var $container = containerHelper.get(interaction); + var state = {}; + var response = interaction.getResponse(); + + if (response) { + state.response = response; + } + + //we store also the choice order if shuffled + if (interaction.attr('shuffle') === true) { + state.order = []; + $('.qti-simpleChoice', $container).each(function() { + state.order.push($(this).data('identifier')); + }); + } + + //store the eliminated choices + if (isEliminable(interaction)) { + state.eliminated = []; + $container.find('.qti-simpleChoice.eliminated').each(function() { + state.eliminated.push($(this).data('identifier')); + }); + } + + return state; +}; + +/** + * Expose the common renderer for the choice interaction + * @exports qtiCommonRenderer/renderers/interactions/ChoiceInteraction + */ +export default { + qtiClass: 'choiceInteraction', + template: tpl, + getData: getCustomData, + render: render, + getContainer: containerHelper.get, + setResponse: setResponse, + getResponse: getResponse, + resetResponse: resetResponse, + destroy: destroy, + setState: setState, + getState: getState +}; diff --git a/src/qtiCommonRenderer/renderers/interactions/EndAttemptInteraction.js b/src/qtiCommonRenderer/renderers/interactions/EndAttemptInteraction.js new file mode 100644 index 00000000..22fbda16 --- /dev/null +++ b/src/qtiCommonRenderer/renderers/interactions/EndAttemptInteraction.js @@ -0,0 +1,182 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2015 (original work) Open Assessment Technologies SA ; + */ + +/** + * CommonRenderer for the EndAttempt interaction + * + * @author Sam Sipasseuth + * @author Bertrand Chevrier + */ +import _ from 'lodash'; +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/endAttemptInteraction'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; +import pciResponse from 'taoQtiItem/qtiCommonRenderer/helpers/PciResponse'; +import __ from 'i18n'; + +/** + * Init rendering, called after template injected into the DOM + * All options are listed in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10402 + * + * @param {object} interaction + * @fires endattempt with the response identifier + */ +function render(interaction, options) { + var $container = containerHelper.get(interaction); + + //on click, + $container.on('click.commonRenderer', function(e) { + //if tts component is loaded and click-to-speak function is activated - we should prevent this listener to go further + if ( + $(e.currentTarget) + .closest('.qti-item') + .hasClass('prevent-click-handler') + ) { + return; + } + $container.val(true); + + containerHelper.triggerResponseChangeEvent(interaction); + + $container.trigger('endattempt', [interaction.attr('responseIdentifier')]); + }); +} + +/** + * Set the response to the rendered interaction. + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10402 + * + * @param {object} interaction + * @param {object} response + */ +function setResponse(interaction, response) { + _setVal(interaction, pciResponse.unserialize(response, interaction)[0]); +} + +/** + * Return the response of the rendered interaction + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10402 + * + * @param {object} interaction + * @returns {object} + */ +function getResponse(interaction) { + var val = containerHelper.get(interaction).val(); + val = val && val !== 'false' && val !== '0'; + return pciResponse.serialize([val], interaction); +} + +/** + * Reset the response ... wondering if it is useful ... + + * @param {type} interaction + */ +function resetResponse(interaction) { + _setVal(interaction, false); +} + +/** + * Set the interaction state. It could be done anytime with any state. + * + * @param {Object} interaction - the interaction instance + * @param {Object} state - the interaction state + */ +function setState(interaction, state) { + if (_.isObject(state)) { + if (state.response) { + interaction.resetResponse(); + interaction.setResponse(state.response); + } + } +} + +/** + * Get the interaction state. + * + * @param {Object} interaction - the interaction instance + * @returns {Object} the interaction current state + */ +function getState(interaction) { + var state = {}; + var response = interaction.getResponse(); + + if (response) { + state.response = response; + } + return state; +} + +/** + * + * @param {Object} interaction + * @param {Boolean} val + */ +function _setVal(interaction, val) { + containerHelper + .get(interaction) + .val(val) + .change(); +} + +/** + * Destroy the interaction to restore the dom as it is before render() is called + * + * @param {Object} interaction + */ +function destroy(interaction) { + //remove event + containerHelper.get(interaction).off('.commonRenderer'); +} + +/** + * Define default template data + * + * @param {Object} interaction + * @param {Object} data + * @returns {Object} + */ +function getCustomData(interaction, data) { + if (!data.attributes.title) { + data.attributes.title = __('End Attempt'); + } + return data; +} + +export default { + qtiClass: 'endAttemptInteraction', + template: tpl, + getData: getCustomData, + render: render, + getContainer: containerHelper.get, + setResponse: setResponse, + getResponse: getResponse, + resetResponse: resetResponse, + destroy: destroy, + setState: setState, + getState: getState +}; diff --git a/src/qtiCommonRenderer/renderers/interactions/ExtendedTextInteraction.js b/src/qtiCommonRenderer/renderers/interactions/ExtendedTextInteraction.js new file mode 100644 index 00000000..13ff8cf9 --- /dev/null +++ b/src/qtiCommonRenderer/renderers/interactions/ExtendedTextInteraction.js @@ -0,0 +1,882 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ + +/** + * @author Sam Sipasseuth + * @author Bertrand Chevrier + */ +import $ from 'jquery'; +import _ from 'lodash'; +import __ from 'i18n'; +import Promise from 'core/promise'; +import strLimiter from 'util/strLimiter'; +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/extendedTextInteraction'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; +import instructionMgr from 'taoQtiItem/qtiCommonRenderer/helpers/instructions/instructionManager'; +import ckEditor from 'ckeditor'; +import ckConfigurator from 'taoQtiItem/qtiCommonRenderer/helpers/ckConfigurator'; +import patternMaskHelper from 'taoQtiItem/qtiCommonRenderer/helpers/patternMask'; +import tooltip from 'ui/tooltip'; + +/** + * Init rendering, called after template injected into the DOM + * All options are listed in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10296 + * + * @param {Object} interaction - the extended text interaction model + * @returns {Promise} rendering is async + */ +var render = function render(interaction) { + return new Promise(function(resolve, reject) { + var $el, expectedLength, minStrings, patternMask, placeholderType, editor; + var isThemeLoaded, _styleUpdater, themeLoaded, _getNumStrings; + var $container = containerHelper.get(interaction); + + var multiple = _isMultiple(interaction); + var limiter = inputLimiter(interaction); + + var placeholderText = interaction.attr('placeholderText'); + + var toolbarType = 'extendedText'; + var ckOptions = { + extraPlugins: 'onchange', + language: 'en', + defaultLanguage: 'en', + resize_enabled: true, + secure: location.protocol === 'https:', + forceCustomDomain: true + }; + + if (!multiple) { + $el = $container.find('textarea'); + if (placeholderText) { + $el.attr('placeholder', placeholderText); + } + if (_getFormat(interaction) === 'xhtml') { + isThemeLoaded = false; + _styleUpdater = function() { + var qtiItemStyle, $editorBody, qtiItem; + + if (editor.document) { + qtiItem = $('.qti-item').get(0); + qtiItemStyle = qtiItem.currentStyle || window.getComputedStyle(qtiItem); + + if (editor.document.$ && editor.document.$.body) { + $editorBody = $(editor.document.$.body); + } else { + $editorBody = $(editor.document.getBody().$); + } + + $editorBody.css({ + 'background-color': 'transparent', + color: qtiItemStyle.color + }); + } + }; + themeLoaded = function() { + isThemeLoaded = true; + _styleUpdater(); + }; + + editor = _setUpCKEditor(interaction, ckOptions); + if (!editor) { + reject('Unable to instantiate ckEditor'); + } + + editor.on('instanceReady', function() { + _styleUpdater(); + + //TAO-6409, disable navigation from cke toolbar + if (editor.container && editor.container.$) { + $(editor.container.$).addClass('no-key-navigation'); + } + + //it seems there's still something done after loaded, so resolved must be defered + _.delay(resolve, 300); + }); + if (editor.status === 'ready' || editor.status === 'loaded') { + _.defer(resolve); + } + editor.on('configLoaded', function() { + editor.config = ckConfigurator.getConfig(editor, toolbarType, ckOptions); + + if (limiter.enabled) { + limiter.listenTextInput(); + } + }); + editor.on('change', function() { + containerHelper.triggerResponseChangeEvent(interaction, {}); + }); + + $(document).on('themechange.themeloader', themeLoaded); + } else { + $el.on('keyup.commonRenderer change.commonRenderer', function() { + containerHelper.triggerResponseChangeEvent(interaction, {}); + }); + + if (limiter.enabled) { + limiter.listenTextInput(); + } + + resolve(); + } + + //multiple inputs + } else { + $el = $container.find('input'); + minStrings = interaction.attr('minStrings'); + expectedLength = interaction.attr('expectedLength'); + patternMask = interaction.attr('patternMask'); + + //setting the checking for minimum number of answers + if (minStrings) { + //get the number of filled inputs + _getNumStrings = function($element) { + var num = 0; + $element.each(function() { + if ($(this).val() !== '') { + num++; + } + }); + + return num; + }; + + minStrings = parseInt(minStrings, 10); + if (minStrings > 0) { + $el.on('blur.commonRenderer', function() { + setTimeout(function() { + //checking if the user was clicked outside of the input fields + + //TODO remove notifications in favor of instructions + + if (!$el.is(':focus') && _getNumStrings($el) < minStrings) { + instructionMgr.appendNotification( + interaction, + __('The minimum number of answers is ') + ' : ' + minStrings, + 'warning' + ); + } + }, 100); + }); + } + } + + //set the fields width + if (expectedLength) { + expectedLength = parseInt(expectedLength, 10); + + if (expectedLength > 0) { + $el.each(function() { + $(this).css('width', expectedLength + 'em'); + }); + } + } + + //set the fields pattern mask + if (patternMask) { + $el.each(function() { + _setPattern($(this), patternMask); + }); + } + + //set the fields placeholder + if (placeholderText) { + /** + * The type of the fileds placeholder: + * multiple - set placeholder for each field + * first - set placeholder only for first field + * none - dont set placeholder + */ + placeholderType = 'first'; + + if (placeholderType === 'multiple') { + $el.each(function() { + $(this).attr('placeholder', placeholderText); + }); + } else if (placeholderType === 'first') { + $el.first().attr('placeholder', placeholderText); + } + } + resolve(); + } + }); +}; + +/** + * Reset the textarea / ckEditor + * @param {Object} interaction - the extended text interaction model + */ +var resetResponse = function(interaction) { + if (_getFormat(interaction) === 'xhtml') { + _getCKEditor(interaction).setData(''); + } else { + containerHelper + .get(interaction) + .find('input, textarea') + .val(''); + } +}; + +/** + * Set the response to the rendered interaction. + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10296 + * + * @param {Object} interaction - the extended text interaction model + * @param {object} response + */ +var setResponse = function(interaction, response) { + var _setMultipleVal = function(identifier, value) { + interaction + .getContainer() + .find('#' + identifier) + .val(value); + }; + + var baseType = interaction.getResponseDeclaration().attr('baseType'); + + if (response.base && response.base[baseType] !== undefined) { + setText(interaction, response.base[baseType]); + } else if (response.list && response.list[baseType]) { + for (var i in response.list[baseType]) { + var serial = response.list.serial === undefined ? '' : response.list.serial[i]; + _setMultipleVal(serial + '_' + i, response.list[baseType][i]); + } + } else { + throw new Error('wrong response format in argument.'); + } +}; + +/** + * Return the response of the rendered interaction + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10296 + * + * @param {Object} interaction - the extended text interaction model + * @returns {object} + */ +var getResponse = function(interaction) { + var $container = containerHelper.get(interaction); + var attributes = interaction.getAttributes(); + var responseDeclaration = interaction.getResponseDeclaration(); + var baseType = responseDeclaration.attr('baseType'); + var numericBase = attributes.base || 10; + var multiple = !!( + attributes.maxStrings && + (responseDeclaration.attr('cardinality') === 'multiple' || + responseDeclaration.attr('cardinality') === 'ordered') + ); + var ret = multiple ? { list: {} } : { base: {} }; + + if (multiple) { + var values = []; + + $container.find('input').each(function(i) { + var $el = $(this); + + if (attributes.placeholderText && $el.val() === attributes.placeholderText) { + values[i] = ''; + } else { + if (baseType === 'integer') { + values[i] = parseInt($el.val(), numericBase); + values[i] = isNaN(values[i]) ? '' : values[i]; + } else if (baseType === 'float') { + values[i] = parseFloat($el.val()); + values[i] = isNaN(values[i]) ? '' : values[i]; + } else if (baseType === 'string') { + values[i] = $el.val(); + } + } + }); + + ret.list[baseType] = values; + } else { + var value = ''; + + if (attributes.placeholderText && _getTextareaValue(interaction) === attributes.placeholderText) { + value = ''; + } else { + if (baseType === 'integer') { + value = parseInt(_getTextareaValue(interaction), numericBase); + } else if (baseType === 'float') { + value = parseFloat(_getTextareaValue(interaction)); + } else if (baseType === 'string') { + value = _getTextareaValue(interaction, true); + } + } + + ret.base[baseType] = isNaN(value) && typeof value === 'number' ? '' : value; + } + + return ret; +}; + +/** + * Creates an input limiter object + * @param {Object} interaction - the extended text interaction + * @returns {Object} the limiter + */ +var inputLimiter = function userInputLimier(interaction) { + var $container = containerHelper.get(interaction); + var expectedLength = interaction.attr('expectedLength'); + var expectedLines = interaction.attr('expectedLines'); + var patternMask = interaction.attr('patternMask'); + var patternRegEx; + var $textarea, $charsCounter, $wordsCounter, maxWords, maxLength; + var enabled = false; + + if (expectedLength || expectedLines || patternMask) { + enabled = true; + + $textarea = $('.text-container', $container); + $charsCounter = $('.count-chars', $container); + $wordsCounter = $('.count-words', $container); + + if (patternMask !== '') { + maxWords = patternMaskHelper.parsePattern(patternMask, 'words'); + maxLength = patternMaskHelper.parsePattern(patternMask, 'chars'); + maxWords = _.isNaN(maxWords) ? undefined : maxWords; + maxLength = _.isNaN(maxLength) ? undefined : maxLength; + if (!maxLength && !maxWords) { + patternRegEx = new RegExp(patternMask); + } + } + } + + /** + * The limiter instance + */ + var limiter = { + /** + * Is the limiter enabled regarding the interaction configuration + */ + enabled: enabled, + + /** + * Listen for text input into the interaction and limit it if necessary + */ + listenTextInput: function listenTextInput() { + var self = this; + + var ignoreKeyCodes = [ + 8, // backspace + 16, // shift + 17, // control + 46, // delete + 37, // arrow left + 38, // arrow up + 39, // arrow right + 40, // arrow down + 35, // home + 36, // end + + // ckeditor specific: + 1114177, // home + 3342401, // Shift + home + 1114181, // end + 3342405, // Shift + end + + 2228232, // Shift + backspace + 2228261, // Shift + arrow left + 4456485, // Alt + arrow left + 2228262, // Shift + arrow up + 2228263, // Shift + arrow right + 4456487, // Alt + arrow right + 2228264, // Shift + arrow down + + 1114120, // Ctrl + backspace + 1114177, // Ctrl + a + 1114202, // Ctrl + z + 1114200 // Ctrl + x + ]; + var triggerKeyCodes = [ + 32, // space + 13, // enter + 2228237 // shift + enter in ckEditor + ]; + var cke; + + var invalidToolip = tooltip.error($container, __('This is not a valid answer'), { + position: 'bottom', + trigger: 'manual' + }); + var patternHandler = function patternHandler(e) { + var isCke = _getFormat(interaction) === 'xhtml'; + var newValue; + if (patternRegEx) { + if (isCke) { + // cke has its own object structure + newValue = e.getData(); + } else { + // covers input + newValue = e.currentTarget.value; + } + + if (!newValue) { + return false; + } + _.debounce(function() { + if (!patternRegEx.test(newValue)) { + $container.addClass('invalid'); + $container.show(); + invalidToolip.show(); + containerHelper.triggerResponseChangeEvent(interaction); + } else { + $container.removeClass('invalid'); + invalidToolip.dispose(); + } + }, 400)(); + } + }; + + /** + * This part works on keyboard input + * + * @param e + * @returns {boolean} + */ + var keyLimitHandler = function keyLimitHandler(e) { + var keyCode = e && e.data ? e.data.keyCode : e.which; + if ( + !_.contains(ignoreKeyCodes, keyCode) && + ((maxWords && self.getWordsCount() >= maxWords && _.contains(triggerKeyCodes, keyCode)) || + (maxLength && self.getCharsCount() >= maxLength)) + ) { + if (e.cancel) { + e.cancel(); + } else { + e.preventDefault(); + e.stopImmediatePropagation(); + } + return false; + } + _.defer(function() { + self.updateCounter(); + }); + }; + + /** + * This part works on drop or paste + * @param e + */ + var nonKeyLimitHandler = function nonKeyLimitHandler(e) { + var newValue; + var oldValue = _getTextareaValue(interaction); + var isCke = _getFormat(interaction) === 'xhtml'; + + if (isCke) { + // cke has its own object structure + newValue = e.data.dataValue; + } else { + // covers input via paste or drop + newValue = e.originalEvent.clipboardData + ? e.originalEvent.clipboardData.getData('text') + : e.originalEvent.dataTransfer.getData('text') || + e.originalEvent.dataTransfer.getData('text/plain') || + ''; + } + + // prevent insertion of non-limited data + if (e.cancel) { + e.cancel(); + } else { + e.preventDefault(); + e.stopImmediatePropagation(); + } + + if (!newValue) { + return false; + } + + // limit by word or character count if required + if (!_.isNull(maxWords)) { + newValue = strLimiter.limitByWordCount(newValue, maxWords - self.getWordsCount()); + } else if (!_.isNull(maxLength)) { + newValue = strLimiter.limitByCharCount(newValue, maxLength - self.getCharsCount()); + } + + // insert the cut-off text + if (isCke) { + _getCKEditor(interaction).insertText(newValue); + } else { + containerHelper + .get(interaction) + .find('textarea') + .val(oldValue + newValue); + } + + _.defer(function() { + self.updateCounter(); + }); + }; + + if (_getFormat(interaction) === 'xhtml') { + cke = _getCKEditor(interaction); + cke.on('key', keyLimitHandler); + cke.on('paste', nonKeyLimitHandler); + // @todo: drop requires cke 4.5 + // cke.on('drop', nonKeyLimitHandler); + } else { + $textarea + .on('keyup.commonRenderer', patternHandler) + .on('keydown.commonRenderer', keyLimitHandler) + .on('paste.commonRenderer drop.commonRenderer', nonKeyLimitHandler); + } + }, + + /** + * Get the number of words that are actually written in the response field + * @return {Number} number of words + */ + getWordsCount: function getWordsCount() { + var value = _getTextareaValue(interaction) || ''; + if (_.isEmpty(value)) { + return 0; + } + // leading and trailing white space don't qualify as words + return value + .trim() + .replace(/\s+/gi, ' ') + .split(' ').length; + }, + + /** + * Get the number of characters that are actually written in the response field + * @return {Number} number of characters + */ + getCharsCount: function getCharsCount() { + var value = _getTextareaValue(interaction) || ''; + return value.length; + }, + + /** + * Update the counter element + */ + updateCounter: function udpateCounter() { + $charsCounter.text(this.getCharsCount()); + $wordsCounter.text(this.getWordsCount()); + } + }; + + return limiter; +}; + +/** + * return the value of the textarea or ckeditor data + * @param {Object} interaction + * @param {Boolean} raw Tells if the returned data does not have to be filtered (i.e. XHTML tags not removed) + * @return {String} the value + */ +var _getTextareaValue = function(interaction, raw) { + if (_getFormat(interaction) === 'xhtml') { + return _ckEditorData(interaction, raw); + } else { + return containerHelper + .get(interaction) + .find('textarea') + .val(); + } +}; + +/** + * Setting the pattern mask for the input, for browsers which doesn't support this feature + * @param {jQuery} $element + * @param {string} pattern + */ +var _setPattern = function _setPattern($element, pattern) { + var patt = new RegExp(pattern); + + //test when some data is entering in the input field + //@todo plug the validator + tooltip + $element.on('keyup.commonRenderer', function() { + $element.removeClass('field-error'); + if (!patt.test($element.val())) { + $element.addClass('field-error'); + } + }); +}; + +/** + * Whether or not multiple strings are expected from the candidate to + * compose a valid response. + * + * @param {Object} interaction - the extended text interaction model + * @returns {Boolean} true if a multiple + */ +var _isMultiple = function _isMultiple(interaction) { + var attributes = interaction.getAttributes(); + var response = interaction.getResponseDeclaration(); + return !!( + attributes.maxStrings && + (response.attr('cardinality') === 'multiple' || response.attr('cardinality') === 'ordered') + ); +}; + +/** + * Instantiate CkEditor for the interaction + * + * @param {Object} interaction - the extended text interaction model + * @param {Object} [options = {}] - the CKEditor configuration options + * @returns {Object} the ckEditor instance (or you'll be in trouble + */ +var _setUpCKEditor = function _setUpCKEditor(interaction, options) { + var $container = containerHelper.get(interaction); + var editor = ckEditor.replace($container.find('.text-container')[0], options || {}); + if (editor) { + $container.data('editor', editor.name); + return editor; + } +}; + +/** + * Destroy CKEditor + * + * @param {Object} interaction - the extended text interaction model + * @param {Object} [options = {}] - the CKEditor configuration options + */ +var _destroyCkEditor = function _destroyCkEditor(interaction) { + var $container = containerHelper.get(interaction); + var name = $container.data('editor'); + var editor; + if (name) { + editor = ckEditor.instances[name]; + } + if (editor) { + editor.destroy(); + $container.removeData('editor'); + } +}; + +/** + * Gets the CKEditor instance + * @param {Object} interaction - the extended text interaction model + * @returns {Object} CKEditor instance + */ +var _getCKEditor = function _getCKEditor(interaction) { + var $container = containerHelper.get(interaction); + var name = $container.data('editor'); + + return ckEditor.instances[name]; +}; + +/** + * get the text content of the ckEditor ( not the entire html ) + * @param {object} interaction the interaction + * @param {Boolean} raw Tells if the returned data does not have to be filtered (i.e. XHTML tags not removed) + * @returns {string} text content of the ckEditor + */ +var _ckEditorData = function _ckEditorData(interaction, raw) { + var editor = _getCKEditor(interaction); + var data = (editor && editor.getData()) || ''; + + if (!raw) { + data = _stripTags(data); + } + + return data; +}; + +/** + * Remove HTML tags from a string + * @param {String} str + * @returns {String} + */ +var _stripTags = function _stripTags(str) { + var tempNode = document.createElement('div'); + tempNode.innerHTML = str; + return tempNode.textContent; +}; + +/** + * Get the interaction format + * @param {Object} interaction - the extended text interaction model + * @returns {String} format in 'plain', 'xhtml', 'preformatted' + */ +var _getFormat = function _getFormat(interaction) { + var format = interaction.attr('format'); + if (_.contains(['plain', 'xhtml', 'preformatted'], format)) { + return format; + } + return 'plain'; +}; + +var enable = function(interaction) { + var $container = containerHelper.get(interaction); + var editor; + + $container.find('input, textarea').removeAttr('disabled'); + + if (_getFormat(interaction) === 'xhtml') { + editor = _getCKEditor(interaction); + if (editor) { + if (editor.status === 'ready') { + editor.setReadOnly(false); + } else { + editor.readOnly = false; + } + } + } +}; + +var disable = function(interaction) { + var $container = containerHelper.get(interaction); + var editor; + + $container.find('input, textarea').attr('disabled', 'disabled'); + + if (_getFormat(interaction) === 'xhtml') { + editor = _getCKEditor(interaction); + if (editor) { + if (editor.status === 'ready') { + editor.setReadOnly(true); + } else { + editor.readOnly = true; + } + } + } +}; + +var clearText = function(interaction) { + setText(interaction, ''); +}; + +var setText = function(interaction, text) { + var limiter = inputLimiter(interaction); + if (_getFormat(interaction) === 'xhtml') { + try { + _getCKEditor(interaction).setData(text, function() { + if (limiter.enabled) { + limiter.updateCounter(); + } + }); + } catch (e) { + console.error('setText error', e); + } + } else { + containerHelper + .get(interaction) + .find('textarea') + .val(text); + if (limiter.enabled) { + limiter.updateCounter(); + } + } +}; + +/** + * Clean interaction destroy + * @param {Object} interaction + */ +var destroy = function destroy(interaction) { + var $container = containerHelper.get(interaction); + var $el = $container.find('input, textarea'); + + if (_getFormat(interaction) === 'xhtml') { + _destroyCkEditor(interaction); + } + + //remove event + $el.off('.commonRenderer'); + $(document).off('.commonRenderer'); + + //remove instructions + instructionMgr.removeInstructions(interaction); + + //remove all references to a cache container + containerHelper.reset(interaction); +}; + +/** + * Set the interaction state. It could be done anytime with any state. + * + * @param {Object} interaction - the interaction instance + * @param {Object} state - the interaction state + */ +var setState = function setState(interaction, state) { + if (_.isObject(state)) { + if (state.response) { + try { + interaction.setResponse(state.response); + } catch (e) { + interaction.resetResponse(); + throw e; + } + } + } +}; + +/** + * Get the interaction state. + * + * @param {Object} interaction - the interaction instance + * @returns {Object} the interaction current state + */ +var getState = function getState(interaction) { + var state = {}; + var response = interaction.getResponse(); + + if (response) { + state.response = response; + } + return state; +}; + +var getCustomData = function(interaction, data) { + var pattern = interaction.attr('patternMask'), + maxWords = parseInt(patternMaskHelper.parsePattern(pattern, 'words')), + maxLength = parseInt(patternMaskHelper.parsePattern(pattern, 'chars')), + expectedLength = parseInt(interaction.attr('expectedLines'), 10); + return _.merge(data || {}, { + maxWords: !isNaN(maxWords) ? maxWords : undefined, + maxLength: !isNaN(maxLength) ? maxLength : undefined, + attributes: !isNaN(expectedLength) ? { expectedLength: expectedLength * 72 } : undefined + }); +}; + +/** + * Expose the common renderer for the extended text interaction + * @exports qtiCommonRenderer/renderers/interactions/ExtendedTextInteraction + */ +export default { + qtiClass: 'extendedTextInteraction', + template: tpl, + render: render, + getContainer: containerHelper.get, + setResponse: setResponse, + getResponse: getResponse, + getData: getCustomData, + resetResponse: resetResponse, + destroy: destroy, + getState: getState, + setState: setState, + + enable: enable, + disable: disable, + clearText: clearText, + setText: setText +}; diff --git a/src/qtiCommonRenderer/renderers/interactions/GapMatchInteraction.js b/src/qtiCommonRenderer/renderers/interactions/GapMatchInteraction.js new file mode 100644 index 00000000..096c74f2 --- /dev/null +++ b/src/qtiCommonRenderer/renderers/interactions/GapMatchInteraction.js @@ -0,0 +1,549 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014-2019 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ + +/** + * @author Sam Sipasseuth + * @author Bertrand Chevrier + */ +import _ from 'lodash'; +import __ from 'i18n'; +import $ from 'jquery'; +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/gapMatchInteraction'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; +import pciResponse from 'taoQtiItem/qtiCommonRenderer/helpers/PciResponse'; +import interact from 'interact'; +import interactUtils from 'ui/interactUtils'; + +/** + * Global variable to count number of choice usages: + * @type {object} + */ +var _choiceUsages = {}; + +var setChoice = function(interaction, $choice, $target) { + var choiceSerial = $choice.data('serial'), + choice = interaction.getChoice(choiceSerial); + + if (!_choiceUsages[choiceSerial]) { + _choiceUsages[choiceSerial] = 0; + } + _choiceUsages[choiceSerial]++; + + $target + .data('serial', choiceSerial) + .html($choice.html()) + .addClass('filled'); + + if ( + !interaction.responseMappingMode && + choice.attr('matchMax') && + _choiceUsages[choiceSerial] >= choice.attr('matchMax') + ) { + $choice.attr('class', 'deactivated'); + } + + containerHelper.triggerResponseChangeEvent(interaction); +}; + +var unsetChoice = function(interaction, $choice) { + var serial = $choice.data('serial'); + var $container = containerHelper.get(interaction); + + $container + .find('.choice-area [data-serial=' + serial + ']') + .removeClass() + .addClass('qti-choice'); + + _choiceUsages[serial]--; + + $choice + .removeClass('filled') + .removeData('serial') + .empty(); + + if (!interaction.swapping) { + //set correct response + containerHelper.triggerResponseChangeEvent(interaction); + } +}; + +var getChoice = function(interaction, identifier) { + var $container = containerHelper.get(interaction); + return $('.choice-area [data-identifier=' + identifier + ']', $container); +}; + +var getGap = function(interaction, identifier) { + var $container = containerHelper.get(interaction); + return $('.qti-flow-container [data-identifier=' + identifier + ']', $container); +}; + +/** + * Init rendering, called after template injected into the DOM + * All options are listed in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10291 + * + * @param {object} interaction + */ +var render = function(interaction) { + var $container = containerHelper.get(interaction); + var $choiceArea = $container.find('.choice-area'); + var $flowContainer = $container.find('.qti-flow-container'); + + var $activeChoice = null; + var $activeDrop = null; + + var isDragAndDropEnabled; + var dragOptions; + var scaleX, scaleY; + + var $bin = $('', { class: 'icon-undo remove-choice', title: __('remove') }); + + var choiceSelector = $choiceArea.selector + ' .qti-choice'; + var gapSelector = $flowContainer.selector + ' .gapmatch-content'; + var filledGapSelector = gapSelector + '.filled'; + var binSelector = $container.selector + ' .remove-choice'; + + var _getChoice = function(serial) { + return $choiceArea.find('[data-serial=' + serial + ']'); + }; + + var _setChoice = function($choice, $target) { + return setChoice(interaction, $choice, $target); + }; + + var _resetSelection = function() { + if ($activeChoice) { + $flowContainer.find('.remove-choice').remove(); + $activeChoice.removeClass('deactivated active'); + $container.find('.empty').removeClass('empty'); + $activeChoice = null; + } + }; + + var _unsetChoice = function($choice) { + return unsetChoice(interaction, $choice); + }; + + var _isInsertionMode = function() { + return $activeChoice && !$activeChoice.hasClass('filled'); + }; + + var _isModeEditing = function() { + return $activeChoice && $activeChoice.hasClass('filled'); + }; + + // Drag & drop handlers + + if (this.getOption && this.getOption('enableDragAndDrop') && this.getOption('enableDragAndDrop').gapMatch) { + isDragAndDropEnabled = this.getOption('enableDragAndDrop').gapMatch; + } + + function _iFrameDragFix(draggableSelector, target) { + interactUtils.iFrameDragFixOn(function() { + if ($activeDrop) { + interact(gapSelector).fire({ + type: 'drop', + target: $activeDrop.eq(0), + relatedTarget: target + }); + } + interact(draggableSelector).fire({ + type: 'dragend', + target: target + }); + }); + } + + if (isDragAndDropEnabled) { + dragOptions = { + inertia: false, + autoScroll: true, + restrict: { + restriction: '.qti-interaction', + endOnly: false, + elementRect: { top: 0, left: 0, bottom: 1, right: 1 } + } + }; + + // makes choices draggables + interact(choiceSelector) + .draggable( + _.assign({}, dragOptions, { + onstart: function(e) { + var $target = $(e.target); + var scale; + $target.addClass('dragged'); + _handleChoiceSelect($target); + + _iFrameDragFix(choiceSelector, e.target); + scale = interactUtils.calculateScale(e.target); + scaleX = scale[0]; + scaleY = scale[1]; + }, + onmove: function(e) { + interactUtils.moveElement(e.target, e.dx / scaleX, e.dy / scaleY); + }, + onend: function(e) { + var $target = $(e.target); + $target.removeClass('dragged'); + + interactUtils.restoreOriginalPosition($target); + interactUtils.iFrameDragFixOff(); + } + }) + ) + .styleCursor(false); + + // makes filled gaps draggables + interact(filledGapSelector) + .draggable( + _.assign({}, dragOptions, { + onstart: function(e) { + var $target = $(e.target); + var scale; + $target.addClass('dragged'); + _handleFilledGapSelect($target); + + _iFrameDragFix(filledGapSelector, e.target); + scale = interactUtils.calculateScale(e.target); + scaleX = scale[0]; + scaleY = scale[1]; + }, + onmove: function(e) { + interactUtils.moveElement(e.target, e.dx / scaleX, e.dy / scaleY); + }, + onend: function(e) { + var $target = $(e.target); + $target.removeClass('dragged'); + + interactUtils.restoreOriginalPosition($target); + + if ($activeChoice) { + _unsetChoice($activeChoice); + _resetSelection(); + } + interactUtils.iFrameDragFixOff(); + } + }) + ) + .styleCursor(false); + + // makes gaps droppables + interact(gapSelector).dropzone({ + overlap: 0.05, + ondragenter: function(e) { + var $target = $(e.target), + $dragged = $(e.relatedTarget); + + $activeDrop = $target; + $target.addClass('dropzone'); + $dragged.addClass('droppable'); + }, + ondrop: function(e) { + _handleGapSelect($(e.target)); + + this.ondragleave(e); + }, + ondragleave: function(e) { + var $target = $(e.target), + $dragged = $(e.relatedTarget); + + $target.removeClass('dropzone'); + $dragged.removeClass('droppable'); + + $activeDrop = null; + } + }); + } + + // Point & click handlers + + interact($container.selector).on('tap', function(e) { + e.stopPropagation(); + _resetSelection(); + }); + + interact(choiceSelector).on('tap', function(e) { + e.stopPropagation(); + _handleChoiceSelect($(e.currentTarget)); + e.preventDefault(); + }); + + interact(gapSelector).on('tap', function(e) { + e.stopPropagation(); + _handleGapSelect($(e.currentTarget)); + e.preventDefault(); + }); + + interact(binSelector).on('tap', function(e) { + e.stopPropagation(); + _unsetChoice($activeChoice); + _resetSelection(); + e.preventDefault(); + }); + + // Common handlers + + function _handleChoiceSelect($target) { + if (($activeChoice && $target.hasClass('active')) || $target.hasClass('deactivated')) { + return; + } + _resetSelection(); + + $activeChoice = $target.addClass('active'); + $(gapSelector).addClass('empty'); + } + + function _handleFilledGapSelect($target) { + $activeChoice = $target; + $(gapSelector).addClass('active'); + } + + function _handleGapSelect($target) { + var choiceSerial, targetSerial; + + if (_isInsertionMode()) { + choiceSerial = $activeChoice.data('serial'); + targetSerial = $target.data('serial'); + + if (targetSerial !== choiceSerial) { + //set choices: + if (targetSerial) { + _unsetChoice($target); + } + + _setChoice($activeChoice, $target); + } + + $activeChoice.removeClass('active'); + $container.find('.empty').removeClass('empty'); + $activeChoice = null; + } else if (_isModeEditing()) { + choiceSerial = $activeChoice.data('serial'); + targetSerial = $target.data('serial'); + + if (targetSerial !== choiceSerial) { + _unsetChoice($activeChoice); + if (targetSerial) { + //swapping: + _unsetChoice($target); + _setChoice(_getChoice(targetSerial), $activeChoice); + } + _setChoice(_getChoice(choiceSerial), $target); + } + + _resetSelection(); + } else if ($target.data('serial') && $target.hasClass('filled')) { + targetSerial = $target.data('serial'); + + $activeChoice = $target; + $activeChoice.addClass('active'); + + $flowContainer + .find('>li>div') + .filter(function() { + return $target.data('serial') !== targetSerial; + }) + .addClass('empty'); + + $choiceArea + .find('>li:not(.deactivated)') + .filter(function() { + return $target.data('serial') !== targetSerial; + }) + .addClass('empty'); + + //append trash bin: + $target.append($bin); + } + } +}; + +var resetResponse = function(interaction) { + var $container = containerHelper.get(interaction); + + $('.gapmatch-content.active', $container).removeClass('active'); + $('.gapmatch-content', $container).each(function() { + unsetChoice(interaction, $(this)); + }); +}; + +var _setPairs = function(interaction, pairs) { + _.each(pairs, function(pair) { + if (pair) { + setChoice( + interaction, + getChoice(interaction, pair[0]), + getGap(interaction, pair[1]).find('.gapmatch-content') + ); + } + }); +}; + +/** + * Set the response to the rendered interaction. + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10291 + * + * @param {object} interaction + * @param {object} response + */ +var setResponse = function(interaction, response) { + resetResponse(interaction); + _setPairs(interaction, pciResponse.unserialize(response, interaction)); +}; + +var _getRawResponse = function(interaction) { + var response = []; + var $container = containerHelper.get(interaction); + $('.gapmatch-content', $container).each(function() { + var choiceSerial = $(this).data('serial'), + pair = []; + + if (choiceSerial) { + pair.push(interaction.getChoice(choiceSerial).attr('identifier')); + } + pair.push($(this).data('identifier')); + + if (pair.length === 2) { + response.push(pair); + } + }); + return response; +}; + +/** + * Return the response of the rendered interaction + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10307 + * + * @param {object} interaction + * @returns {object} + */ +var getResponse = function(interaction) { + return pciResponse.serialize(_getRawResponse(interaction), interaction); +}; + +var destroy = function(interaction) { + var $container = containerHelper.get(interaction); + + //remove event + interact($container.selector).unset(); + interact($container.find('.choice-area').selector + ' .qti-choice').unset(); + interact($container.find('.qti-flow-container').selector + ' .gapmatch-content').unset(); + interact($container.find('.remove-choice').selector).unset(); + + //restore selection + $container.find('.gapmatch-content').empty(); + $container.find('.active').removeClass('active'); + $container.find('.remove-choice').remove(); + $container.find('.empty').removeClass('empty'); + + //remove all references to a cache container + containerHelper.reset(interaction); +}; + +/** + * Set the interaction state. It could be done anytime with any state. + * + * @param {Object} interaction - the interaction instance + * @param {Object} state - the interaction state + */ +var setState = function setState(interaction, state) { + var $container; + + if (_.isObject(state)) { + if (state.response) { + interaction.resetResponse(); + interaction.setResponse(state.response); + } + + //restore order of previously shuffled choices + if (_.isArray(state.order) && state.order.length === _.size(interaction.getChoices())) { + $container = containerHelper.get(interaction); + + $('.choice-area .qti-choice', $container) + .sort(function(a, b) { + var aIndex = _.indexOf(state.order, $(a).data('identifier')); + var bIndex = _.indexOf(state.order, $(b).data('identifier')); + if (aIndex > bIndex) { + return 1; + } + if (aIndex < bIndex) { + return -1; + } + return 0; + }) + .detach() + .appendTo($('.choice-area', $container)); + } + } +}; + +/** + * Get the interaction state. + * + * @param {Object} interaction - the interaction instance + * @returns {Object} the interaction current state + */ +var getState = function getState(interaction) { + var $container; + var state = {}; + var response = interaction.getResponse(); + + if (response) { + state.response = response; + } + + //we store also the choice order if shuffled + if (interaction.attr('shuffle') === true) { + $container = containerHelper.get(interaction); + + state.order = []; + $('.choice-area .qti-choice', $container).each(function() { + state.order.push($(this).data('identifier')); + }); + } + return state; +}; + +/** + * Expose the common renderer for the gapmatch interaction + * @exports qtiCommonRenderer/renderers/interactions/GapMatchInteraction + */ +export default { + qtiClass: 'gapMatchInteraction', + template: tpl, + render: render, + getContainer: containerHelper.get, + setResponse: setResponse, + getResponse: getResponse, + resetResponse: resetResponse, + destroy: destroy, + setState: setState, + getState: getState +}; diff --git a/src/qtiCommonRenderer/renderers/interactions/GraphicAssociateInteraction.js b/src/qtiCommonRenderer/renderers/interactions/GraphicAssociateInteraction.js new file mode 100644 index 00000000..3c5aad3a --- /dev/null +++ b/src/qtiCommonRenderer/renderers/interactions/GraphicAssociateInteraction.js @@ -0,0 +1,549 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ + +/** + * @author Bertrand Chevrier + */ +import $ from 'jquery'; +import _ from 'lodash'; +import __ from 'i18n'; +import Promise from 'core/promise'; +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/graphicAssociateInteraction'; +import graphic from 'taoQtiItem/qtiCommonRenderer/helpers/Graphic'; +import pciResponse from 'taoQtiItem/qtiCommonRenderer/helpers/PciResponse'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; +import instructionMgr from 'taoQtiItem/qtiCommonRenderer/helpers/instructions/instructionManager'; + +/** + * Init rendering, called after template injected into the DOM + * All options are listed in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 + * + * @param {object} interaction + */ +var render = function render(interaction) { + var self = this; + + return new Promise(function(resolve, reject) { + var $container = containerHelper.get(interaction); + var background = interaction.object.attributes; + var $imageBox = $('.main-image-box', $container); + interaction._vsets = []; + + $container.off('resized.qti-widget.resolve').one('resized.qti-widget.resolve', resolve); + + interaction.paper = graphic.responsivePaper('graphic-paper-' + interaction.serial, interaction.serial, { + width: background.width, + height: background.height, + img: self.resolveUrl(background.data), + imgId: 'bg-image-' + interaction.serial, + container: $container + }); + + //call render choice for each interaction's choices + _.forEach(interaction.getChoices(), _.partial(_renderChoice, interaction)); + + //make the paper clear the selection by clicking it + _paperUnSelect(interaction); + + //set up the constraints instructions + instructionMgr.minMaxChoiceInstructions(interaction, { + min: interaction.attr('minAssociations'), + max: interaction.attr('maxAssociations'), + getResponse: _getRawResponse, + onError: function(data) { + if (data && data.target) { + graphic.highlightError(data.target); + } + } + }); + }); +}; + +/** + * Render a choice inside the paper. + * Please note that the choice renderer isn't implemented separately because it relies on the Raphael paper instead of the DOM. + * @param {Paper} paper - the raphael paper to add the choices to + * @param {Object} interaction + * @param {Object} choice - the hotspot choice to add to the interaction + */ +var _renderChoice = function _renderChoice(interaction, choice) { + var shape = choice.attr('shape'); + var coords = choice.attr('coords'); + var maxAssociations = interaction.attr('maxAssociations'); + + var rElement = graphic + .createElement(interaction.paper, shape, coords, { + id: choice.serial, + title: __('Select this area to start an association') + }) + .data('max', choice.attr('matchMax')) + .data('matching', 0) + .removeData('assocs') + .click(function() { + var self = this; + var active, assocs; + + //can't create more associations than the maxAssociations attr + if (maxAssociations > 0 && _getRawResponse(interaction).length >= maxAssociations) { + _shapesUnSelectable(interaction); + instructionMgr.validateInstructions(interaction, { choice: choice, target: this }); + return; + } + + if (this.selectable) { + active = _getActiveElement(interaction); + if (active) { + //increment the matching counter + active.data('matching', active.data('matching') + 1); + this.data('matching', this.data('matching') + 1); + + //attach the response to the active (not the dest) + assocs = active.data('assocs') || []; + assocs.push(choice.id()); + active.data('assocs', assocs); + + //and create the path + _createPath(interaction, active, this, function onRemove() { + //decrement the matching counter + active.data('matching', active.data('matching') - 1); + self.data('matching', self.data('matching') - 1); + + //detach the response from the active + active.data('assocs', _.remove(active.data('assocs') || [], choice.id())); + + containerHelper.triggerResponseChangeEvent(interaction); + instructionMgr.validateInstructions(interaction, { choice: choice, target: self }); + }); + } + _shapesUnSelectable(interaction); + } else if (this.active) { + graphic.updateElementState(this, 'basic', __('Select another area to complete the association')); + this.active = false; + _shapesUnSelectable(interaction); + } else if (_isMatchable(this)) { + graphic.updateElementState(this, 'active', __('Select this area to start an association')); + this.active = true; + _shapesSelectable(interaction, this); + } + + containerHelper.triggerResponseChangeEvent(interaction); + instructionMgr.validateInstructions(interaction, { choice: choice, target: this }); + }); +}; + +/** + * By clicking the paper image the shapes are restored to their default state + * @private + * @param {Object} interaction + */ +var _paperUnSelect = function _paperUnSelect(interaction) { + var $container = containerHelper.get(interaction); + var image = interaction.paper.getById('bg-image-' + interaction.serial); + if (image) { + image.click(function() { + _shapesUnSelectable(interaction); + $container.trigger('unselect.graphicassociate'); + }); + } +}; + +/** + * Get the element that has the active state + * @private + * @param {Object} interaction + * @returns {Raphael.Element} the active element + */ +var _getActiveElement = function _getActiveElement(interaction) { + var active; + _.forEach(interaction.getChoices(), function(choice) { + var element = interaction.paper.getById(choice.serial); + if (element && element.active === true) { + active = element; + return false; + } + }); + return active; +}; + +/** + * Create a path from a src element to a destination. + * The path is selectable and can be removed by itself + * @private + * @param {Object} interaction + * @param {Raphael.Element} srcElement - the path starts from this shape + * @param {Raphael.Element} destElement - the path ends to this shape + * @param {Function} onRemove - called back on path remove + */ +var _createPath = function _createPath(interaction, srcElement, destElement, onRemove) { + var $container = containerHelper.get(interaction); + + //virtual set, not a raphael one, just to group the elements + var vset = []; + + //get the middle point of the source shape + var src = srcElement.getBBox(); + var sx = src.x + src.width / 2; + var sy = src.y + src.height / 2; + + //get the middle point of the source shape + var dest = destElement.getBBox(); + var dx = dest.x + dest.width / 2; + var dy = dest.y + dest.height / 2; + + //create a path with bullets at the beginning and the end + var srcBullet = interaction.paper.circle(sx, sy, 3).attr(graphic._style['assoc-bullet']); + + var destBullet = interaction.paper.circle(dx, dy, 3).attr(graphic._style['assoc-bullet']); + + var path = interaction.paper + .path('M' + sx + ',' + sy + 'L' + sx + ',' + sy) + .attr(graphic._style.assoc) + .animate({ path: 'M' + sx + ',' + sy + 'L' + dx + ',' + dy }, 300); + + //create an overall layer that make easier the path selection + var layer = interaction.paper.path('M' + sx + ',' + sy + 'L' + dx + ',' + dy).attr(graphic._style['assoc-layer']); + + //get the middle of the path + var midPath = layer.getPointAtLength(layer.getTotalLength() / 2); + + //create an hidden background for the closer + var closerBg = interaction.paper + .circle(midPath.x, midPath.y, 9) + .attr(graphic._style['close-bg']) + .toBack(); + + //create an hidden closer + var closer = interaction.paper + .path(graphic._style.close.path) + .attr(graphic._style.close) + .transform('T' + (midPath.x - 9) + ',' + (midPath.y - 9)) + .attr('title', _('Click again to remove')) + .toBack(); + + //the path is below the shapes + srcElement.toFront(); + destElement.toFront(); + + //add the path into a set + vset = [srcBullet, path, destBullet, layer, closerBg, closer]; + interaction._vsets.push(vset); + + //to identify the element of the set outside the context + _.invoke(vset, 'data', 'assoc-path', true); + + //enable to select the path by clicking the invisible layer + layer.click(function selectLigne() { + if (closer.attrs.opacity === 0) { + showCloser(); + } else { + hideCloser(); + } + }); + + $container.on('unselect.graphicassociate', function() { + hideCloser(); + }); + + function showCloser() { + closerBg + .toFront() + .animate({ opacity: 0.8 }, 300) + .click(removeSet); + closer + .toFront() + .animate({ opacity: 1 }, 300) + .click(removeSet); + } + + function hideCloser() { + if (closerBg && closerBg.type) { + closerBg + .animate({ opacity: 0 }, 300, function() { + closerBg.toBack(); + }) + .unclick(); + closer + .animate({ opacity: 0 }, 300, function() { + closer.toBack(); + }) + .unclick(); + } + } + + //remove set handler + function removeSet() { + _.invoke(vset, 'remove'); + interaction._vsets = _.without(interaction._vsets, vset); + if (typeof onRemove === 'function') { + onRemove(); + } + } +}; + +/** + * Makes the shapes selectable + * @private + * @param {Object} interaction + * @param {Raphael.Element} active - the active shape + */ +var _shapesSelectable = function _shapesSelectable(interaction, active) { + var assocs = active.data('assocs') || []; + + //update the shape state + _.forEach(interaction.getChoices(), function(choice) { + var element; + if (!_.contains(assocs, choice.id())) { + element = interaction.paper.getById(choice.serial); + if (!element.active && element.id !== active.id && _isMatchable(element, active)) { + element.selectable = true; + graphic.updateElementState(element, 'selectable'); + } + } + }); +}; + +/** + * Makes all the shapes UNselectable + * @private + * @param {Object} interaction + */ +var _shapesUnSelectable = function _shapesUnSelectable(interaction) { + _.forEach(interaction.getChoices(), function(choice) { + var element = interaction.paper.getById(choice.serial); + if (element) { + element.selectable = false; + element.active = false; + graphic.updateElementState(element, 'basic'); + } + }); +}; + +/** + * Check if a shape can accept matches + * @private + * @param {Raphael.Element} element - the shape + * @returns {Boolean} true if the element is matchable + */ +var _isMatchable = function(element) { + var matchable = false; + var matching, matchMax; + if (element) { + matchMax = element.data('max') || 0; + matching = element.data('matching') || 0; + matchable = matchMax === 0 || matchMax > matching; + } + return matchable; +}; + +/** + * Get the response from the interaction + * @private + * @param {Object} interaction + * @returns {Array} the response in raw format + */ +var _getRawResponse = function _getRawResponse(interaction) { + var responses = []; + _.forEach(interaction.getChoices(), function(choice) { + var element = interaction.paper.getById(choice.serial); + var assocs = element.data('assocs'); + if (element && assocs) { + responses = responses.concat( + _.map(assocs, function(id) { + return [choice.id(), id]; + }) + ); + } + }); + return responses; +}; + +/** + * Set the response to the rendered interaction. + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 + * + * Special value: the empty object value {} resets the interaction responses + * + * @param {object} interaction + * @param {object} response + */ +var setResponse = function(interaction, response) { + var responseValues; + if (response && interaction.paper) { + try { + responseValues = pciResponse.unserialize(response, interaction); + } catch (e) {} + + if (_.isArray(responseValues)) { + //create an object with choiceId => shapeElement + var map = _.transform(interaction.getChoices(), function(res, choice) { + res[choice.id()] = interaction.paper.getById(choice.serial); + }); + _.forEach(responseValues, function(responseValue) { + var el1, el2; + if (_.isArray(responseValue) && responseValue.length === 2) { + el1 = map[responseValue[0]]; + el2 = map[responseValue[1]]; + if (el1 && el2) { + graphic.trigger(el1, 'click'); + graphic.trigger(el2, 'click'); + } + } + }); + } + } +}; + +/** + * Reset the current responses of the rendered interaction. + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 + * + * Special value: the empty object value {} resets the interaction responses + * + * @param {object} interaction + * @param {object} response + */ +var resetResponse = function resetResponse(interaction) { + var toRemove = []; + + //reset response and state bound to shapes + _.forEach(interaction.getChoices(), function(choice) { + var element = interaction.paper.getById(choice.serial); + if (element) { + element.data({ + max: choice.attr('matchMax'), + matching: 0, + assocs: [] + }); + } + }); + + //remove the paths, but outside the forEach as it is implemented as a linked list + interaction.paper.forEach(function(elt) { + if (elt.data('assoc-path')) { + toRemove.push(elt); + } + }); + _.invoke(toRemove, 'remove'); +}; + +/** + * Return the response of the rendered interaction + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 + * + * @param {object} interaction + * @returns {object} + */ +var getResponse = function(interaction) { + var raw = _getRawResponse(interaction); + var response = pciResponse.serialize(_getRawResponse(interaction), interaction); + return response; +}; + +/** + * Clean interaction destroy + * @param {Object} interaction + */ +var destroy = function destroy(interaction) { + var $container; + if (interaction.paper) { + $container = containerHelper.get(interaction); + + $(window).off('resize.qti-widget.' + interaction.serial); + $container.off('resize.qti-widget.' + interaction.serial); + + interaction.paper.clear(); + instructionMgr.removeInstructions(interaction); + + $container.off('.graphicassociate'); + + $('.main-image-box', $container) + .empty() + .removeAttr('style'); + $('.image-editor', $container).removeAttr('style'); + $('ul', $container).empty(); + } + + //remove all references to a cache container + containerHelper.reset(interaction); +}; + +/** + * Set the interaction state. It could be done anytime with any state. + * + * @param {Object} interaction - the interaction instance + * @param {Object} state - the interaction state + */ +var setState = function setState(interaction, state) { + if (_.isObject(state)) { + if (state.response) { + interaction.resetResponse(); + interaction.setResponse(state.response); + } + } +}; + +/** + * Get the interaction state. + * + * @param {Object} interaction - the interaction instance + * @returns {Object} the interaction current state + */ +var getState = function getState(interaction) { + var $container; + var state = {}; + var response = interaction.getResponse(); + + if (response) { + state.response = response; + } + return state; +}; + +/** + * Expose the common renderer for the hotspot interaction + * @exports qtiCommonRenderer/renderers/interactions/GraphicAssociateInteraction + */ +export default { + qtiClass: 'graphicAssociateInteraction', + template: tpl, + render: render, + getContainer: containerHelper.get, + setResponse: setResponse, + getResponse: getResponse, + resetResponse: resetResponse, + destroy: destroy, + setState: setState, + getState: getState +}; diff --git a/src/qtiCommonRenderer/renderers/interactions/GraphicGapMatchInteraction.js b/src/qtiCommonRenderer/renderers/interactions/GraphicGapMatchInteraction.js new file mode 100644 index 00000000..866fb36f --- /dev/null +++ b/src/qtiCommonRenderer/renderers/interactions/GraphicGapMatchInteraction.js @@ -0,0 +1,703 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014-2019 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ + +/** + * @author Bertrand Chevrier + */ +import $ from 'jquery'; +import _ from 'lodash'; +import __ from 'i18n'; +import module from 'module'; +import Promise from 'core/promise'; +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/graphicGapMatchInteraction'; +import graphic from 'taoQtiItem/qtiCommonRenderer/helpers/Graphic'; +import pciResponse from 'taoQtiItem/qtiCommonRenderer/helpers/PciResponse'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; +import instructionMgr from 'taoQtiItem/qtiCommonRenderer/helpers/instructions/instructionManager'; +import interact from 'interact'; +import interactUtils from 'ui/interactUtils'; + +var isDragAndDropEnabled; + +// this represents the state for the active droppable zone +// we need it only to access the active dropzone in the iFrameFix +// should be removed when the old test runner is discarded +var activeDrop = null; + +/** + * Global variable to count number of choice usages: + * @type {object} + */ +var _choiceUsages = {}; + +/** + * This options enables to support old items created with the wrong + * direction in the directedpairs. + * + * @deprecated + */ +var isDirectedPairFlipped = module.config().flipDirectedPair; + +/** + * Check if a shape can accept matches + * @private + * @param {Raphael.Element} element - the shape + * @returns {Boolean} true if the element is matchable + */ +var _isMatchable = function(element) { + var matchable = false; + var matching, matchMax; + if (element) { + matchMax = element.data('max') || 0; + matching = element.data('matching') || []; + matchable = matchMax === 0 || matchMax > matching.length; + } + return matchable; +}; + +/** + * Makes the shapes selectable (at least those who can still accept matches) + * @private + * @param {Object} interaction + */ +var _shapesSelectable = function _shapesSelectable(interaction) { + var tooltip = __('Select the area to add an image'); + + //update the shape state + _.forEach(interaction.getChoices(), function(choice) { + var element = interaction.paper.getById(choice.serial); + if (_isMatchable(element)) { + element.selectable = true; + graphic.setStyle(element, 'selectable'); + graphic.updateTitle(element, tooltip); + } + }); + + //update the gap images tooltip + _.forEach(interaction.gapFillers, function(gapFiller) { + gapFiller.forEach(function(element) { + graphic.updateTitle(element, tooltip); + }); + }); +}; + +/** + * Makes all the shapes UNselectable + * @private + * @param {Object} interaction + */ +var _shapesUnSelectable = function _shapesUnSelectable(interaction) { + _.forEach(interaction.getChoices(), function(choice) { + var element = interaction.paper.getById(choice.serial); + if (element) { + element.selectable = false; + graphic.setStyle(element, 'basic'); + graphic.updateTitle(element, __('Select an image first')); + } + }); + + //update the gap images tooltip + _.forEach(interaction.gapFillers, function(gapFiller) { + gapFiller.forEach(function(element) { + graphic.updateTitle(element, __('Remove')); + }); + }); +}; + +/** + * By clicking the paper image the shapes are restored to their default state + * @private + * @param {Object} interaction + */ +var _paperUnSelect = function _paperUnSelect(interaction) { + var $container = containerHelper.get(interaction); + var $gapImages = $('ul > li', $container); + var bgImage = interaction.paper.getById('bg-image-' + interaction.serial); + if (bgImage) { + interact(bgImage.node).on('tap', function() { + _shapesUnSelectable(interaction); + $gapImages.removeClass('active'); + }); + } +}; + +/** + * Sets a choice and marks as disabled if at max + * @private + * @param {Object} interaction + * @param {JQuery Element} $choice + */ +var _setChoice = function _setChoice(interaction, $choice) { + var choiceSerial = $choice.data('serial'); + var choice = interaction.getGapImg(choiceSerial); + var matchMax; + var usages; + + if (!_choiceUsages[choiceSerial]) { + _choiceUsages[choiceSerial] = 0; + } + + _choiceUsages[choiceSerial]++; + + // disable choice if maxium usage reached + if (!interaction.responseMappingMode && choice.attr('matchMax')) { + matchMax = +choice.attr('matchMax'); + usages = +_choiceUsages[choiceSerial]; + + // note: if matchMax is 0, then test taker is allowed unlimited usage of that choice + if (matchMax !== 0 && matchMax <= usages) { + interact($choice.get(0)).draggable(false); + $choice.addClass('disabled'); + $choice.removeClass('selectable'); + } + } +}; + +/** + * Unset a choice and unmark as disabled + * @private + * @param {Object} interaction + * @param {JQuery Element} $choice + */ +var _unsetChoice = function _unsetChoice(interaction, $choice) { + var choiceSerial = $choice.data('serial'); + + _choiceUsages[choiceSerial]--; + + $choice.removeClass('disabled'); + $choice.addClass('selectable'); + interact($choice.get(0)).draggable(true); +}; + +/** + * Select a shape (= hotspot) (a gap image must be active) + * @private + * @param {Object} interaction + * @param {Raphael.Element} element - the selected shape + * @param {Boolean} [trackResponse = true] - if the selection trigger a response chane + */ +var _selectShape = function _selectShape(interaction, element, trackResponse) { + var $img, $clone, gapFiller, id, bbox, shapeOffset, activeOffset, matching, currentCount; + + //lookup for the active element + var $container = containerHelper.get(interaction); + var $gapList = $('ul', $container); + var $active = $gapList.find('.active:first'); + var $imageBox = $('.main-image-box', $container); + var boxOffset = $imageBox.offset(); + + if (typeof trackResponse === 'undefined') { + trackResponse = true; + } + + if ($active.length) { + //the macthing elements are linked to the shape + id = $active.data('identifier'); + matching = element.data('matching') || []; + matching.push(id); + element.data('matching', matching); + currentCount = matching.length; + + //the image to clone + $img = $active.find('img'); + + //then reset the state of the shapes and the gap images + _shapesUnSelectable(interaction); + $gapList.children().removeClass('active'); + + _setChoice(interaction, $active); + + $clone = $img.clone(); + shapeOffset = $(element.node).offset(); + activeOffset = $active.offset(); + + $clone.css({ + position: 'absolute', + display: 'block', + 'z-index': 10000, + opacity: 0.8, + top: activeOffset.top - boxOffset.top, + left: activeOffset.left - boxOffset.left + }); + + $clone.appendTo($imageBox); + $clone.animate( + { + top: shapeOffset.top - boxOffset.top, + left: shapeOffset.left - boxOffset.left + }, + 200, + function animationEnd() { + var gapFillerImage; + + $clone.remove(); + + //extract some coords for positioning + bbox = element.getBBox(); + + //create an image into the paper and move it to the selected shape + gapFiller = graphic + .createBorderedImage(interaction.paper, { + url: $img.attr('src'), + left: bbox.x + 8 * (currentCount - 1), + top: bbox.y + 8 * (currentCount - 1), + width: parseInt($img.attr('width'), 10), + height: parseInt($img.attr('height'), 10), + padding: 0, + border: false, + shadow: true + }) + .data('identifier', id) + .toFront(); + + gapFillerImage = gapFiller[2].node; + interact(gapFillerImage).on('tap', function(e) { + var target = e.currentTarget; + var rElement = interaction.paper.getById(target.raphaelid); + + e.preventDefault(); + e.stopPropagation(); + + // adding a new gapfiller on the hotspot by simulating a click on the underlying shape... + if ($gapList.find('.active').length > 0) { + interactUtils.tapOn(element.node); + + // ... or removing the existing gapfiller + } else { + //update the element matching array + element.data( + 'matching', + _.without(element.data('matching') || [], rElement.data('identifier')) + ); + + //delete interaction.gapFillers[interaction.gapFillers.indexOf(gapFiller)]; + interaction.gapFillers = _.without(interaction.gapFillers, gapFiller); + + gapFiller.remove(); + + _unsetChoice(interaction, $active); + + containerHelper.triggerResponseChangeEvent(interaction); + } + }); + + interaction.gapFillers.push(gapFiller); + + containerHelper.triggerResponseChangeEvent(interaction); + } + ); + } +}; + +/** + * Render a choice (= hotspot) inside the paper. + * Please note that the choice renderer isn't implemented separately because it relies on the Raphael paper instead of the DOM. + * + * @private + * @param {Object} interaction + * @param {Object} choice - the hotspot choice to add to the interaction + */ +var _renderChoice = function _renderChoice(interaction, choice) { + //create the shape + var rElement = graphic + .createElement(interaction.paper, choice.attr('shape'), choice.attr('coords'), { + id: choice.serial, + title: __('Select an image first'), + hover: false + }) + .data('max', choice.attr('matchMax')) + .data('matching', []); + + interact(rElement.node).on('tap', function onClickShape() { + handleShapeSelect(); + }); + + if (isDragAndDropEnabled) { + interact(rElement.node).dropzone({ + overlap: 0.15, + ondragenter: function() { + if (_isMatchable(rElement)) { + graphic.setStyle(rElement, 'hover'); + activeDrop = rElement.node; + } + }, + ondrop: function() { + if (_isMatchable(rElement)) { + graphic.setStyle(rElement, 'selectable'); + handleShapeSelect(); + activeDrop = null; + } + }, + ondragleave: function() { + if (_isMatchable(rElement)) { + graphic.setStyle(rElement, 'selectable'); + activeDrop = null; + } + } + }); + } + + function handleShapeSelect() { + // check if can make the shape selectable on click + if (_isMatchable(rElement) && rElement.selectable === true) { + _selectShape(interaction, rElement); + } + } +}; + +var _iFrameDragFix = function _iFrameDragFix(draggableSelector, target) { + interactUtils.iFrameDragFixOn(function() { + if (activeDrop) { + interact(activeDrop).fire({ + type: 'drop', + target: activeDrop, + relatedTarget: target + }); + } + interact(draggableSelector).fire({ + type: 'dragend', + target: target + }); + }); +}; + +/** + * Render the list of gap fillers + * @private + * @param {Object} interaction + * @param {jQueryElement} $gapList - the list than contains the orderers + */ +var _renderGapList = function _renderGapList(interaction, $gapList) { + var gapFillersSelector = $gapList.selector + ' li'; + var dragOptions; + var scaleX, scaleY; + + interact(gapFillersSelector).on('tap', function onClickGapImg(e) { + e.stopPropagation(); + e.preventDefault(); + toggleActiveGapState($(e.currentTarget)); + }); + + if (isDragAndDropEnabled) { + dragOptions = { + inertia: false, + autoScroll: true, + restrict: { + restriction: '.qti-interaction', + endOnly: false, + elementRect: { top: 0, left: 0, bottom: 1, right: 1 } + } + }; + + $(gapFillersSelector).each(function(index, gap) { + interact(gap) + .draggable( + _.assign({}, dragOptions, { + onstart: function(e) { + var $target = $(e.target); + var scale; + _setActiveGapState($target); + $target.addClass('dragged'); + + _iFrameDragFix(gapFillersSelector, e.target); + scale = interactUtils.calculateScale(e.target); + scaleX = scale[0]; + scaleY = scale[1]; + }, + onmove: function(e) { + interactUtils.moveElement(e.target, e.dx / scaleX, e.dy / scaleY); + }, + onend: function(e) { + var $target = $(e.target); + _setInactiveGapState($target); + $target.removeClass('dragged'); + interactUtils.restoreOriginalPosition($target); + interactUtils.iFrameDragFixOff(); + } + }) + ) + .styleCursor(false); + }); + } + + function toggleActiveGapState($target) { + if (!$target.hasClass('disabled')) { + if ($target.hasClass('active')) { + _setInactiveGapState($target); + } else { + _setActiveGapState($target); + } + } + } + + function _setActiveGapState($target) { + $gapList.children('li').removeClass('active'); + $target.addClass('active'); + _shapesSelectable(interaction); + } + + function _setInactiveGapState($target) { + $target.removeClass('active'); + _shapesUnSelectable(interaction); + } +}; + +/** + * Init rendering, called after template injected into the DOM + * All options are listed in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 + * + * @param {object} interaction + * @return {Promise} + */ +var render = function render(interaction) { + var self = this; + + return new Promise(function(resolve) { + var $container = containerHelper.get(interaction); + var $gapList = $('ul.source', $container); + var background = interaction.object.attributes; + + interaction.gapFillers = []; + + if ( + self.getOption && + self.getOption('enableDragAndDrop') && + self.getOption('enableDragAndDrop').graphicGapMatch + ) { + isDragAndDropEnabled = self.getOption('enableDragAndDrop').graphicGapMatch; + } + + $container.off('resized.qti-widget.resolve').one('resized.qti-widget.resolve', resolve); + + //create the paper + interaction.paper = graphic.responsivePaper('graphic-paper-' + interaction.serial, interaction.serial, { + width: background.width, + height: background.height, + img: self.resolveUrl(background.data), + imgId: 'bg-image-' + interaction.serial, + container: $container, + resize: function(newSize, factor) { + $gapList.css('max-width', newSize + 'px'); + if (factor !== 1) { + $gapList.find('img').each(function() { + var $img = $(this); + $img.width($img.attr('width') * factor); + $img.height($img.attr('height') * factor); + }); + } + } + }); + + //call render choice for each interaction's choices + _.forEach(interaction.getChoices(), _.partial(_renderChoice, interaction)); + + //create the list of gap images + _renderGapList(interaction, $gapList); + + //clicking the paper to reset selection + _paperUnSelect(interaction); + }); +}; + +/** + * Get the responses from the interaction + * @private + * @param {Object} interaction + * @returns {Array} of matches + */ +var _getRawResponse = function _getRawResponse(interaction) { + var pairs = []; + _.forEach(interaction.getChoices(), function(choice) { + var element = interaction.paper.getById(choice.serial); + if (element && _.isArray(element.data('matching'))) { + _.forEach(element.data('matching'), function(gapImg) { + //backward support of previous order + if (isDirectedPairFlipped) { + pairs.push([choice.id(), gapImg]); + } else { + pairs.push([gapImg, choice.id()]); + } + }); + } + }); + return _.sortBy(pairs, [0, 1]); +}; + +/** + * Set the response to the rendered interaction. + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 + * + * Special value: the empty object value {} resets the interaction responses + * + * @param {object} interaction + * @param {object} response + */ +var setResponse = function(interaction, response) { + var $container = containerHelper.get(interaction); + var responseValues; + if (response && interaction.paper) { + try { + responseValues = pciResponse.unserialize(response, interaction); + } catch (e) { + responseValues = null; + } + + if (_.isArray(responseValues)) { + _.forEach(interaction.getChoices(), function(choice) { + var element = interaction.paper.getById(choice.serial); + if (element) { + _.forEach(responseValues, function(pair) { + var responseChoice; + var responseGap; + if (pair.length === 2) { + //backward support of previous order + responseChoice = isDirectedPairFlipped ? pair[0] : pair[1]; + responseGap = isDirectedPairFlipped ? pair[1] : pair[0]; + if (responseChoice === choice.id()) { + $('[data-identifier=' + responseGap + ']', $container).addClass('active'); + _selectShape(interaction, element, false); + } + } + }); + } + }); + } + } +}; + +/** + * Reset the current responses of the rendered interaction. + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 + * + * Special value: the empty object value {} resets the interaction responses + * + * @param {object} interaction + */ +var resetResponse = function resetResponse(interaction) { + _shapesUnSelectable(interaction); + + _.forEach(interaction.gapFillers, function(gapFiller) { + interactUtils.tapOn(gapFiller.items[2][0]); // this refers to the gapFiller image + }); +}; + +/** + * Return the response of the rendered interaction + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 + * + * @param {object} interaction + * @returns {object} + */ +var getResponse = function(interaction) { + var raw = _getRawResponse(interaction); + return pciResponse.serialize(raw, interaction); +}; + +/** + * Clean interaction destroy + * @param {Object} interaction + */ +var destroy = function destroy(interaction) { + var $container; + if (interaction.paper) { + $container = containerHelper.get(interaction); + + $(window).off('resize.qti-widget.' + interaction.serial); + $container.off('resize.qti-widget.' + interaction.serial); + + interaction.paper.clear(); + instructionMgr.removeInstructions(interaction); + + $('.main-image-box', $container) + .empty() + .removeAttr('style'); + $('.image-editor', $container).removeAttr('style'); + $('ul', $container).empty(); + + interact($container.find('ul.source li').selector).unset(); // gapfillers + interact($container.find('.main-image-box rect').selector).unset(); // choices/hotspot + } + //remove all references to a cache container + containerHelper.reset(interaction); +}; + +/** + * Set the interaction state. It could be done anytime with any state. + * + * @param {Object} interaction - the interaction instance + * @param {Object} state - the interaction state + */ +var setState = function setState(interaction, state) { + if (_.isObject(state)) { + if (state.response) { + interaction.resetResponse(); + interaction.setResponse(state.response); + } + } +}; + +/** + * Get the interaction state. + * + * @param {Object} interaction - the interaction instance + * @returns {Object} the interaction current state + */ +var getState = function getState(interaction) { + var state = {}; + var response = interaction.getResponse(); + + if (response) { + state.response = response; + } + return state; +}; + +/** + * Expose the common renderer for the hotspot interaction + * @exports qtiCommonRenderer/renderers/interactions/HotspotInteraction + */ +export default { + qtiClass: 'graphicGapMatchInteraction', + template: tpl, + render: render, + getContainer: containerHelper.get, + setResponse: setResponse, + getResponse: getResponse, + resetResponse: resetResponse, + destroy: destroy, + setState: setState, + getState: getState, + isDirectedPairFlipped: isDirectedPairFlipped +}; diff --git a/src/qtiCommonRenderer/renderers/interactions/GraphicOrderInteraction.js b/src/qtiCommonRenderer/renderers/interactions/GraphicOrderInteraction.js new file mode 100644 index 00000000..ef1d886c --- /dev/null +++ b/src/qtiCommonRenderer/renderers/interactions/GraphicOrderInteraction.js @@ -0,0 +1,472 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ + +/** + * @author Bertrand Chevrier + */ +import $ from 'jquery'; +import _ from 'lodash'; +import __ from 'i18n'; +import Promise from 'core/promise'; +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/graphicOrderInteraction'; +import graphic from 'taoQtiItem/qtiCommonRenderer/helpers/Graphic'; +import pciResponse from 'taoQtiItem/qtiCommonRenderer/helpers/PciResponse'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; +import instructionMgr from 'taoQtiItem/qtiCommonRenderer/helpers/instructions/instructionManager'; + +/** + * Init rendering, called after template injected into the DOM + * All options are listed in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 + * + * @param {object} interaction + */ +var render = function render(interaction) { + var self = this; + + return new Promise(function(resolve, reject) { + var $container = containerHelper.get(interaction); + var $orderList = $('ul', $container); + var background = interaction.object.attributes; + + $container.off('resized.qti-widget.resolve').one('resized.qti-widget.resolve', resolve); + + //create the paper + interaction.paper = graphic.responsivePaper('graphic-paper-' + interaction.serial, interaction.serial, { + width: background.width, + height: background.height, + img: self.resolveUrl(background.data), + imgId: 'bg-image-' + interaction.serial, + container: $container + }); + + //create the list of number to order + _renderOrderList(interaction, $orderList); + + //call render choice for each interaction's choices + _.forEach(interaction.getChoices(), _.partial(_renderChoice, interaction, $orderList)); + + //set up the constraints instructions + instructionMgr.minMaxChoiceInstructions(interaction, { + min: interaction.attr('minChoices'), + max: interaction.attr('maxChoices'), + getResponse: _getRawResponse, + onError: function(data) { + graphic.highlightError(data.target); + } + }); + }); +}; + +/** + * Render a choice inside the paper. + * Please note that the choice renderer isn't implemented separately because it relies on the Raphael paper instead of the DOM. + * @private + * @param {Object} interaction + * @param {jQueryElement} $orderList - the list than contains the orderers + * @param {Object} choice - the hotspot choice to add to the interaction + */ +var _renderChoice = function _renderChoice(interaction, $orderList, choice) { + var rElement = graphic + .createElement(interaction.paper, choice.attr('shape'), choice.attr('coords'), { + id: choice.serial, + title: __('Select this area') + }) + .click(function(e) { + //if tts component is loaded and click-to-speak function is activated - we should prevent this listener to go further + if ( + $(e.currentTarget) + .closest('.qti-item') + .hasClass('prevent-click-handler') + ) { + return; + } + if (this.active) { + _unselectShape(interaction.paper, this, $orderList); + } else { + _selectShape(interaction.paper, this, $orderList); + } + containerHelper.triggerResponseChangeEvent(interaction); + instructionMgr.validateInstructions(interaction, { choice: choice }); + }); +}; + +/** + * Render the list of numbers + * @private + * @param {Object} interaction + * @param {jQueryElement} $orderList - the list than contains the orderers + */ +var _renderOrderList = function _renderOrderList(interaction, $orderList) { + var $orderers; + var size = _.size(interaction.getChoices()); + var min = interaction.attr('minChoices'); + var max = interaction.attr('maxChoices'); + + //calculate the number of orderer to display + if (max > 0 && max < size) { + size = max; + } else if (min > 0 && min < size) { + size = min; + } + + //add them to the list + _.times(size, function(index) { + var position = index + 1; + var $orderer = $('
  • ' + position + '
  • '); + if (index === 0) { + $orderer.addClass('active'); + } + $orderList.append($orderer); + }); + + //create related svg texts + _createTexts(interaction.paper, size, $orderList); + + //bind the activation event + $orderers = $orderList.children('li'); + $orderers.click(function(e) { + e.preventDefault(); + var $orderer = $(this); + + if (!$orderer.hasClass('active') && !$orderer.hasClass('disabled')) { + $orderers.removeClass('active'); + $orderer.addClass('active'); + } + }); +}; + +/** + * Select a shape to position an order + * @private + * @param {Raphael.Paper} paper - the interaction paper + * @param {Raphael.element} element - the selected shape + * @param {jQueryElement} $orderList - the list than contains the orderers + */ +var _selectShape = function _selectShape(paper, element, $orderList) { + //lookup for the active number + var $active = $orderList.find('.active:first'); + if ($active.length && $active.data('number') > 0) { + //associate the current number directly to the element + element.data('number', $active.data('number')); + element.active = true; + _showText(paper, element); + graphic.updateElementState(element, 'active'); + + //update the state of the order list + $active + .toggleClass('active disabled') + .siblings(':not(.disabled)') + .first() + .toggleClass('active'); + } +}; + +/** + * Unselect a shape to free the position + * @private + * @param {Raphael.Paper} paper - the interaction paper + * @param {Raphael.element} element - the unselected shape + * @param {jQueryElement} $orderList - the list than contains the orderers + */ +var _unselectShape = function _unselectShape(paper, element, $orderList) { + var number = element.data('number'); + + //update element state + element.active = false; + _hideText(paper, element); + element.removeData('number'); + graphic.updateElementState(element, 'basic'); + + //reset order list state and activate the removed number + $orderList + .children() + .removeClass('active') + .filter('[data-number=' + number + ']') + .removeClass('disabled') + .addClass('active'); +}; + +/** + * Creates ALL the texts (the numbers to display in the shapes). They are created styled but hidden. + * + * @private + * @param {Raphael.Paper} paper - the interaction paper + * @param {Number} size - the number of numbers to create... + * @param {jQueryElement} $orderList - the list than contains the orderers + * @return {Array} the creates text element + */ +var _createTexts = function _createTexts(paper, size) { + var texts = []; + _.times(size, function(index) { + var number = index + 1; + var text = graphic.createText(paper, { + id: 'text-' + number, + content: number, + title: __('Remove'), + style: 'order-text', + hide: true + }); + + //clicking the text will has the same effect that clicking the shape: unselect. + text.click(function() { + paper.forEach(function(element) { + if (element.data('number') === number && element.events) { + //we just need to retrieve the right element + //call the click event + var evt = _.where(element.events, { name: 'click' }); + if (evt.length && evt[0] && typeof evt[0].f === 'function') { + evt[0].f.call(element); + } + } + }); + }); + texts.push(text); + }); + return texts; +}; + +/** + * Show the text that match the element's number. + * We need to display it at the center of the shape. + * @private + * @param {Raphael.Paper} paper - the interaction paper + * @param {Raphael.Element} element - the element to show the text for + */ +var _showText = function _showText(paper, element) { + var bbox = element.getBBox(); + var transf; + + //we retrieve the good text from it's id + var text = paper.getById('text-' + element.data('number')); + if (text) { + //move it to the center of the shape (using absolute transform), and than display it + transf = 'T' + (bbox.x + bbox.width / 2) + ',' + (bbox.y + bbox.height / 2); + text.transform(transf) + .show() + .toFront(); + } +}; + +/** + * Hide an element text. + * @private + * @param {Raphael.Paper} paper - the interaction paper + * @param {Raphael.Element} element - the element to hide the text for + */ +var _hideText = function _hideText(paper, element) { + var text = paper.getById('text-' + element.data('number')); + if (text) { + text.hide(); + } +}; + +/** + * Get the responses from the interaction + * @private + * @param {Object} interaction + * @returns {Array} of points + */ +var _getRawResponse = function _getRawResponse(interaction) { + var response = []; + _.forEach(interaction.getChoices(), function(choice) { + var elt = interaction.paper.getById(choice.serial); + if (elt && elt.data('number')) { + response.push({ + index: elt.data('number'), + id: choice.id() + }); + } + }); + return _(response) + .sortBy('index') + .map('id') + .value(); +}; + +/** + * Set the response to the rendered interaction. + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 + * + * Special value: the empty object value {} resets the interaction responses + * + * @param {object} interaction + * @param {object} response + */ +var setResponse = function(interaction, response) { + var responseValues; + var $container = containerHelper.get(interaction); + var $orderList = $('ul', $container); + if (response && interaction.paper) { + try { + //try to unserualize tthe pci response + responseValues = pciResponse.unserialize(response, interaction); + } catch (e) {} + + if (_.isArray(responseValues)) { + _.forEach(responseValues, function(responseValue, index) { + var element; + var number = index + 1; + + //get the choice that match the response + var choice = _(interaction.getChoices()) + .where({ attributes: { identifier: responseValue } }) + .first(); + if (choice) { + element = interaction.paper.getById(choice.serial); + if (element) { + //activate the orderer to be consistant + $orderList.children('[data-number=' + number + ']').addClass('active'); + + //select the related shape + _selectShape(interaction.paper, element, $orderList); + } + } + }); + } + } +}; + +/** + * Reset the current responses of the rendered interaction. + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 + * + * Special value: the empty object value {} resets the interaction responses + * + * @param {object} interaction + * @param {object} response + */ +var resetResponse = function resetResponse(interaction) { + var $container = containerHelper.get(interaction); + var $orderList = $('ul', $container); + + _.forEach(interaction.getChoices(), function(choice) { + var element = interaction.paper.getById(choice.serial); + if (element) { + _unselectShape(interaction.paper, element, $orderList); + } + }); + + $orderList + .children('li') + .removeClass('active disabled') + .first() + .addClass('active'); +}; + +/** + i* Return the response of the rendered interaction + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 + * + * @param {object} interaction + * @returns {object} + */ +var getResponse = function(interaction) { + return pciResponse.serialize(_getRawResponse(interaction), interaction); +}; + +/** + * Clean interaction destroy + * @param {Object} interaction + */ +var destroy = function destroy(interaction) { + var $container; + if (interaction.paper) { + $container = containerHelper.get(interaction); + + $(window).off('resize.qti-widget.' + interaction.serial); + $container.off('resize.qti-widget.' + interaction.serial); + + interaction.paper.clear(); + instructionMgr.removeInstructions(interaction); + + $('.main-image-box', $container) + .empty() + .removeAttr('style'); + $('.image-editor', $container).removeAttr('style'); + $('ul', $container).empty(); + } + + //remove all references to a cache container + containerHelper.reset(interaction); +}; + +/** + * Set the interaction state. It could be done anytime with any state. + * + * @param {Object} interaction - the interaction instance + * @param {Object} state - the interaction state + */ +var setState = function setState(interaction, state) { + if (_.isObject(state)) { + if (state.response) { + interaction.resetResponse(); + interaction.setResponse(state.response); + } + } +}; + +/** + * Get the interaction state. + * + * @param {Object} interaction - the interaction instance + * @returns {Object} the interaction current state + */ +var getState = function getState(interaction) { + var $container; + var state = {}; + var response = interaction.getResponse(); + + if (response) { + state.response = response; + } + return state; +}; + +/** + * Expose the common renderer for the interaction + * @exports qtiCommonRenderer/renderers/interactions/SelectPointInteraction + */ +export default { + qtiClass: 'graphicOrderInteraction', + template: tpl, + render: render, + getContainer: containerHelper.get, + setResponse: setResponse, + getResponse: getResponse, + resetResponse: resetResponse, + destroy: destroy, + setState: setState, + getState: getState +}; diff --git a/src/qtiCommonRenderer/renderers/interactions/HotspotInteraction.js b/src/qtiCommonRenderer/renderers/interactions/HotspotInteraction.js new file mode 100644 index 00000000..07050a10 --- /dev/null +++ b/src/qtiCommonRenderer/renderers/interactions/HotspotInteraction.js @@ -0,0 +1,273 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ + +/** + * @author Bertrand Chevrier + */ +import $ from 'jquery'; +import _ from 'lodash'; +import __ from 'i18n'; +import Promise from 'core/promise'; +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/hotspotInteraction'; +import graphic from 'taoQtiItem/qtiCommonRenderer/helpers/Graphic'; +import pciResponse from 'taoQtiItem/qtiCommonRenderer/helpers/PciResponse'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; +import instructionMgr from 'taoQtiItem/qtiCommonRenderer/helpers/instructions/instructionManager'; + +/** + * Init rendering, called after template injected into the DOM + * All options are listed in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 + * + * @param {object} interaction + */ +var render = function render(interaction) { + var self = this; + + return new Promise(function(resolve, reject) { + var $container = containerHelper.get(interaction); + var background = interaction.object.attributes; + + $container.off('resized.qti-widget.resolve').one('resized.qti-widget.resolve', resolve); + + interaction.paper = graphic.responsivePaper('graphic-paper-' + interaction.serial, interaction.serial, { + width: background.width, + height: background.height, + img: self.resolveUrl(background.data), + container: $container + }); + + //call render choice for each interaction's choices + _.forEach(interaction.getChoices(), _.partial(_renderChoice, interaction)); + + //set up the constraints instructions + instructionMgr.minMaxChoiceInstructions(interaction, { + min: interaction.attr('minChoices'), + max: interaction.attr('maxChoices'), + getResponse: _getRawResponse, + onError: function(data) { + if (data.target.active) { + data.target.active = false; + graphic.updateElementState(this, 'basic', __('Select this area')); + graphic.highlightError(data.target); + containerHelper.triggerResponseChangeEvent(interaction); + $container.trigger('inactiveChoice.qti-widget', [data.choice, data.target]); + } + } + }); + }); +}; + +/** + * Render a choice inside the paper. + * Please note that the choice renderer isn't implemented separately because it relies on the Raphael paper instead of the DOM. + * @param {Paper} paper - the raphael paper to add the choices to + * @param {Object} interaction + * @param {Object} choice - the hotspot choice to add to the interaction + */ +var _renderChoice = function _renderChoice(interaction, choice) { + var $container = containerHelper.get(interaction); + var rElement = graphic + .createElement(interaction.paper, choice.attr('shape'), choice.attr('coords'), { + id: choice.serial, + title: __('Select this area') + }) + .click(function() { + if (this.active) { + graphic.updateElementState(this, 'basic', __('Select this area')); + this.active = false; + $container.trigger('inactiveChoice.qti-widget', [choice, this]); + } else { + graphic.updateElementState(this, 'active', __('Click again to remove')); + this.active = true; + $container.trigger('activeChoice.qti-widget', [choice, this]); + } + containerHelper.triggerResponseChangeEvent(interaction); + instructionMgr.validateInstructions(interaction, { choice: choice, target: this }); + }); +}; + +/** + * Get the response from the interaction + * @private + * @param {Object} interaction + * @returns {Array} the response in raw format + */ +var _getRawResponse = function _getRawResponse(interaction) { + return _(interaction.getChoices()) + .map(function(choice) { + var rElement = interaction.paper.getById(choice.serial); + return rElement && rElement.active === true ? choice.id() : false; + }) + .filter(_.isString) + .value(); +}; + +/** + * Set the response to the rendered interaction. + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 + * + * Special value: the empty object value {} resets the interaction responses + * + * @param {object} interaction + * @param {object} response + */ +var setResponse = function(interaction, response) { + var responseValues; + if (response && interaction.paper) { + try { + responseValues = pciResponse.unserialize(response, interaction); + } catch (e) {} + + if (_.isArray(responseValues)) { + _.forEach(interaction.getChoices(), function(choice) { + var rElement; + if (_.contains(responseValues, choice.attributes.identifier)) { + rElement = interaction.paper.getById(choice.serial); + if (rElement) { + rElement.active = true; + graphic.updateElementState(rElement, 'active', __('Click again to remove')); + instructionMgr.validateInstructions(interaction, { choice: choice, target: rElement }); + } + } + }); + } + } +}; + +/** + * Reset the current responses of the rendered interaction. + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 + * + * Special value: the empty object value {} resets the interaction responses + * + * @param {object} interaction + * @param {object} response + */ +var resetResponse = function resetResponse(interaction) { + _.forEach(interaction.getChoices(), function(choice) { + var element = interaction.paper.getById(choice.serial); + if (element) { + element.active = false; + graphic.updateElementState(element, 'basic'); + } + }); + instructionMgr.resetInstructions(interaction); +}; + +/** + * Return the response of the rendered interaction + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 + * + * @param {object} interaction + * @returns {object} + */ +var getResponse = function(interaction) { + var raw = _getRawResponse(interaction); + var response = pciResponse.serialize(_getRawResponse(interaction), interaction); + return response; +}; + +/** + * Clean interaction destroy + * @param {Object} interaction + */ +var destroy = function destroy(interaction) { + var $container; + if (interaction.paper) { + $container = containerHelper.get(interaction); + + $(window).off('resize.qti-widget.' + interaction.serial); + $container.off('resize.qti-widget.' + interaction.serial); + + interaction.paper.clear(); + instructionMgr.removeInstructions(interaction); + + $('.main-image-box', $container) + .empty() + .removeAttr('style'); + $('.image-editor', $container).removeAttr('style'); + } + + //remove all references to a cache container + containerHelper.reset(interaction); +}; +/** + * Set the interaction state. It could be done anytime with any state. + * + * @param {Object} interaction - the interaction instance + * @param {Object} state - the interaction state + */ +var setState = function setState(interaction, state) { + if (_.isObject(state)) { + if (state.response) { + interaction.resetResponse(); + interaction.setResponse(state.response); + } + } +}; + +/** + * Get the interaction state. + * + * @param {Object} interaction - the interaction instance + * @returns {Object} the interaction current state + */ +var getState = function getState(interaction) { + var $container; + var state = {}; + var response = interaction.getResponse(); + + if (response) { + state.response = response; + } + return state; +}; + +/** + * Expose the common renderer for the hotspot interaction + * @exports qtiCommonRenderer/renderers/interactions/HotspotInteraction + */ +export default { + qtiClass: 'hotspotInteraction', + template: tpl, + render: render, + getContainer: containerHelper.get, + setResponse: setResponse, + getResponse: getResponse, + resetResponse: resetResponse, + destroy: destroy, + setState: setState, + getState: getState +}; diff --git a/src/qtiCommonRenderer/renderers/interactions/HottextInteraction.js b/src/qtiCommonRenderer/renderers/interactions/HottextInteraction.js new file mode 100644 index 00000000..d9e80077 --- /dev/null +++ b/src/qtiCommonRenderer/renderers/interactions/HottextInteraction.js @@ -0,0 +1,205 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ + +/** + * @author Sam Sipasseuth + * @author Bertrand Chevrier + */ +import $ from 'jquery'; +import _ from 'lodash'; +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/hottextInteraction'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; +import instructionMgr from 'taoQtiItem/qtiCommonRenderer/helpers/instructions/instructionManager'; +import pciResponse from 'taoQtiItem/qtiCommonRenderer/helpers/PciResponse'; + +/** + * 'pseudo-label' is technically a div that behaves like a label. + * This allows the usage of block elements inside the fake label + */ +var pseudoLabel = function(interaction) { + var $container = containerHelper.get(interaction); + + var setChoice = function($choice, interaction) { + var $inupt = $choice.find('input'); + + if ($inupt.prop('checked') || $inupt.hasClass('disabled')) { + $inupt.prop('checked', false); + } else { + var maxChoices = parseInt(interaction.attr('maxChoices')); + var currentChoices = _.values(_getRawResponse(interaction)).length; + + if (currentChoices < maxChoices || maxChoices === 0) { + $inupt.prop('checked', true); + } + } + containerHelper.triggerResponseChangeEvent(interaction); + instructionMgr.validateInstructions(interaction, { choice: $choice }); + }; + + $('.hottext', $container).on('click', function(e) { + e.preventDefault(); + setChoice($(this), interaction); + }); +}; + +/** + * Init rendering, called after template injected into the DOM + * All options are listed in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10278 + * + * @param {object} interaction + */ +var render = function(interaction) { + pseudoLabel(interaction); + + //set up the constraints instructions + instructionMgr.minMaxChoiceInstructions(interaction, { + min: interaction.attr('minChoices'), + max: interaction.attr('maxChoices'), + getResponse: _getRawResponse, + onError: function(data) { + var $input, $choice, $icon; + if (data.choice && data.choice.length) { + $choice = data.choice.addClass('error'); + $input = $choice.find('input'); + $icon = $choice.find(' > label > span').addClass('error cross'); + + setTimeout(function() { + $input.prop('checked', false); + $choice.removeClass('error'); + $icon.removeClass('error cross'); + }, 350); + } + } + }); +}; + +var resetResponse = function(interaction) { + var $container = containerHelper.get(interaction); + $('input', $container).prop('checked', false); +}; + +/** + * Set the response to the rendered interaction. + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10333 + * + * @param {object} interaction + * @param {object} response + */ +var setResponse = function(interaction, response) { + var $container = containerHelper.get(interaction); + + try { + _.each(pciResponse.unserialize(response, interaction), function(identifier) { + $container.find('input[value="' + identifier + '"]').prop('checked', true); + }); + instructionMgr.validateInstructions(interaction); + } catch (e) { + throw new Error('wrong response format in argument : ' + e); + } +}; + +var _getRawResponse = function(interaction) { + var values = []; + var $container = containerHelper.get(interaction); + $('input:checked', $container).each(function() { + values.push($(this).val()); + }); + return values; +}; + +/** + * Return the response of the rendered interaction + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10333 + * + * @param {object} interaction + * @returns {object} + */ +var getResponse = function(interaction) { + return pciResponse.serialize(_getRawResponse(interaction), interaction); +}; + +/** + * Clean interaction destroy + * @param {Object} interaction + */ +var destroy = function destroy(interaction) { + var $container = containerHelper.get(interaction); + + //restore selected choices: + $container.find('.hottext').off('click'); + + //remove all references to a cache container + containerHelper.reset(interaction); +}; + +/** + * Set the interaction state. It could be done anytime with any state. + * + * @param {Object} interaction - the interaction instance + * @param {Object} state - the interaction state + */ +var setState = function setState(interaction, state) { + if (_.isObject(state)) { + if (state.response) { + interaction.resetResponse(); + interaction.setResponse(state.response); + } + } +}; + +/** + * Get the interaction state. + * + * @param {Object} interaction - the interaction instance + * @returns {Object} the interaction current state + */ +var getState = function getState(interaction) { + var $container; + var state = {}; + var response = interaction.getResponse(); + + if (response) { + state.response = response; + } + return state; +}; + +export default { + qtiClass: 'hottextInteraction', + template: tpl, + render: render, + getContainer: containerHelper.get, + setResponse: setResponse, + getResponse: getResponse, + resetResponse: resetResponse, + destroy: destroy, + setState: setState, + getState: getState +}; diff --git a/src/qtiCommonRenderer/renderers/interactions/InlineChoiceInteraction.js b/src/qtiCommonRenderer/renderers/interactions/InlineChoiceInteraction.js new file mode 100644 index 00000000..294aea87 --- /dev/null +++ b/src/qtiCommonRenderer/renderers/interactions/InlineChoiceInteraction.js @@ -0,0 +1,274 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ + +/** + * @author Sam Sipasseuth + * @author Bertrand Chevrier + */ +import $ from 'jquery'; +import _ from 'lodash'; +import __ from 'i18n'; +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/inlineChoiceInteraction'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; +import instructionMgr from 'taoQtiItem/qtiCommonRenderer/helpers/instructions/instructionManager'; +import pciResponse from 'taoQtiItem/qtiCommonRenderer/helpers/PciResponse'; +import tooltip from 'ui/tooltip'; +import 'select2'; + +/** + * The value of the "empty" option + * @type String + */ +var _emptyValue = 'empty'; + +var _defaultOptions = { + allowEmpty: true, + placeholderText: __('select a choice') +}; + +/** + * Init rendering, called after template injected into the DOM + * All options are listed in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 + * + * @param {object} interaction + */ +var render = function(interaction, options) { + var opts = _.clone(_defaultOptions); + var required = !!interaction.attr('required'); + var choiceTooltip; + var $container = containerHelper.get(interaction); + + _.extend(opts, options); + + if (opts.allowEmpty && !required) { + $container.find('option[value=' + _emptyValue + ']').text('--- ' + __('leave empty') + ' ---'); + } else { + $container.find('option[value=' + _emptyValue + ']').remove(); + } + + $container.select2({ + width: 'element', + placeholder: opts.placeholderText, + minimumResultsForSearch: -1, + dropdownCssClass: 'qti-inlineChoiceInteraction-dropdown' + }); + + var $el = $container.select2('container'); + + if (required) { + //set up the tooltip plugin for the input + choiceTooltip = tooltip.warning($el, __('A choice must be selected')); + + if ($container.val() === '') { + choiceTooltip.show(); + } + } + + $container + .on('change', function(e) { + //if tts component is loaded and click-to-speak function is activated - we must fix the situation when select2 prevents tts from working + //for this a "one-moment" handler of option click is added and removed after event fired + if ( + $(e.currentTarget) + .closest('.qti-item') + .hasClass('prevent-click-handler') + ) { + var $selectedIndex = $(e.currentTarget)[0].options.selectedIndex + ? $(e.currentTarget)[0].options.selectedIndex + : null; + $container.find('option').one('click', function(e) { + e.stopPropagation(); + }); + $container + .find('option') + .eq($selectedIndex) + .trigger('click'); + } + + if (required && $container.val() !== '') { + choiceTooltip.hide(); + } + + containerHelper.triggerResponseChangeEvent(interaction); + }) + .on('select2-open', function() { + if (required) { + choiceTooltip.hide(); + } + }) + .on('select2-close', function() { + if (required && $container.val() === '') { + choiceTooltip.show(); + } + }); +}; + +var resetResponse = function(interaction) { + _setVal(interaction, _emptyValue); +}; + +var _setVal = function(interaction, choiceIdentifier) { + containerHelper + .get(interaction) + .val(choiceIdentifier) + .select2('val', choiceIdentifier); +}; + +/** + * Set the response to the rendered interaction. + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 + * + * @param {object} interaction + * @param {object} response + */ +var setResponse = function(interaction, response) { + _setVal(interaction, pciResponse.unserialize(response, interaction)[0]); +}; + +var _getRawResponse = function(interaction) { + var value = containerHelper.get(interaction).val(); + return value && value !== _emptyValue ? [value] : []; +}; + +/** + * Return the response of the rendered interaction + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 + * + * @param {object} interaction + * @returns {object} + */ +var getResponse = function(interaction) { + return pciResponse.serialize(_getRawResponse(interaction), interaction); +}; + +/** + * Clean interaction destroy + * @param {Object} interaction + */ +var destroy = function(interaction) { + var $container = containerHelper.get(interaction); + + //remove event + $(document).off('.commonRenderer'); + + $container.select2('destroy'); + + //remove instructions + instructionMgr.removeInstructions(interaction); + + //remove all references to a cache container + containerHelper.reset(interaction); +}; + +/** + * Set the interaction state. It could be done anytime with any state. + * + * @param {Object} interaction - the interaction instance + * @param {Object} state - the interaction state + */ +var setState = function setState(interaction, state) { + var $container; + + if (_.isObject(state)) { + if (state.response) { + interaction.resetResponse(); + interaction.setResponse(state.response); + } + + //restore order of previously shuffled choices + if (_.isArray(state.order) && state.order.length === _.size(interaction.getChoices())) { + $container = containerHelper.get(interaction); + + //just in case the dropdown is opened + $container.select2('disable').select2('close'); + + $('option[data-identifier]', $container) + .sort(function(a, b) { + var aIndex = _.indexOf(state.order, $(a).data('identifier')); + var bIndex = _.indexOf(state.order, $(b).data('identifier')); + if (aIndex > bIndex) { + return 1; + } + if (aIndex < bIndex) { + return -1; + } + return 0; + }) + .detach() + .appendTo($container); + + $container.select2('enable'); + } + } +}; + +/** + * Get the interaction state. + * + * @param {Object} interaction - the interaction instance + * @returns {Object} the interaction current state + */ +var getState = function getState(interaction) { + var $container; + var state = {}; + var response = interaction.getResponse(); + + if (response) { + state.response = response; + } + + //we store also the choice order if shuffled + if (interaction.attr('shuffle') === true) { + $container = containerHelper.get(interaction); + + state.order = []; + $('option[data-identifier]', $container).each(function() { + state.order.push($(this).data('identifier')); + }); + } + return state; +}; + +/** + * Expose the common renderer for the inline choice interaction + * @exports qtiCommonRenderer/renderers/interactions/InlineChoiceInteraction + */ +export default { + qtiClass: 'inlineChoiceInteraction', + template: tpl, + render: render, + getContainer: containerHelper.get, + setResponse: setResponse, + getResponse: getResponse, + resetResponse: resetResponse, + destroy: destroy, + setState: setState, + getState: getState +}; diff --git a/src/qtiCommonRenderer/renderers/interactions/MatchInteraction.js b/src/qtiCommonRenderer/renderers/interactions/MatchInteraction.js new file mode 100644 index 00000000..51c73924 --- /dev/null +++ b/src/qtiCommonRenderer/renderers/interactions/MatchInteraction.js @@ -0,0 +1,536 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ + +/** + * @author Sam Sipasseuth + * @author Bertrand Chevrier + */ +import $ from 'jquery'; +import _ from 'lodash'; +import __ from 'i18n'; +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/matchInteraction'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; +import instructionMgr from 'taoQtiItem/qtiCommonRenderer/helpers/instructions/instructionManager'; +import pciResponse from 'taoQtiItem/qtiCommonRenderer/helpers/PciResponse'; + +/** + * TODO do not use global context var, it's value is shared between interaction instances + * + * Flag to not throw warning instruction if already + * displaying the warning. If such a flag is not used, + * disturbances can be seen by the candidate if he clicks + * like hell on choices. + */ +var inWarning = false; + +/** + * Init rendering, called after template injected into the DOM + * All options are listed in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10296 + * + * @param {object} interaction + */ +var render = function(interaction) { + var $container = containerHelper.get(interaction); + + // Initialize instructions system. + _setInstructions(interaction); + + $container.on('click.commonRenderer', 'input[type=checkbox]', function(e) { + _onCheckboxSelected(interaction, e); + }); + + instructionMgr.validateInstructions(interaction); +}; + +/** + * Set the response to the rendered interaction. + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10296 + * + * @param {object} interaction + * @param {object} response + */ +var setResponse = function(interaction, response) { + var $container = containerHelper.get(interaction); + response = _filterResponse(response); + + if (typeof response.list !== 'undefined' && typeof response.list.directedPair !== 'undefined') { + _(response.list.directedPair).forEach(function(directedPair) { + var x = $('th[data-identifier=' + directedPair[0] + ']', $container).index() - 1; + var y = $('th[data-identifier=' + directedPair[1] + ']', $container) + .parent() + .index(); + + $('.matrix > tbody tr', $container) + .eq(y) + .find('input[type=checkbox]') + .eq(x) + .prop('checked', true); + }); + } + + instructionMgr.validateInstructions(interaction); +}; + +/** + * Return the response of the rendered interaction + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10296 + * + * @param {object} interaction + * @returns {object} + */ +var getResponse = function(interaction) { + var response = pciResponse.serialize(_getRawResponse(interaction), interaction); + return response; +}; + +var resetResponse = function(interaction) { + var $container = containerHelper.get(interaction); + $('input[type=checkbox]:checked', $container).each(function() { + $(this).prop('checked', false); + }); + + instructionMgr.validateInstructions(interaction); +}; + +var _filterResponse = function(response) { + if (typeof response.list === 'undefined') { + // Maybe it's a base? + if (typeof response.base === 'undefined') { + // Oops, it is even not a base. + throw 'The given response is not compliant with PCI JSON representation.'; + } else { + // It's a base, Is it a directedPair? Null? + if (response.base === null) { + return { list: { directedPair: [] } }; + } else if (typeof response.base.directedPair === 'undefined') { + // Oops again, it is not a directedPair. + throw 'The matchInteraction only accepts directedPair values as responses.'; + } else { + return { list: { directedPair: [response.base.directedPair] } }; + } + } + } else if (typeof response.list.directedPair === 'undefined') { + // Oops, not a directedPair. + throw 'The matchInteraction only accept directedPair values as responses.'; + } else { + return response; + } +}; + +var _getRawResponse = function(interaction) { + var $container = containerHelper.get(interaction); + var values = []; + + $container.find('input[type=checkbox]:checked').each(function() { + values.push(_inferValue(this)); + }); + + return values; +}; + +var _inferValue = function(element) { + var $element = $(element); + var $container = $element.closest('.match-interaction-area'); + var y = $element.closest('tr').index(); + var x = $element.closest('td').index(); + var firstId = $('.matrix > thead th', $container) + .eq(x) + .data('identifier'); + var secondId = $('.matrix > tbody th', $container) + .eq(y) + .data('identifier'); + return [firstId, secondId]; +}; + +var _onCheckboxSelected = function(interaction, e) { + var choice; + var currentResponse = _getRawResponse(interaction); + var minAssociations = interaction.attr('minAssociations'); + var maxAssociations = interaction.attr('maxAssociations'); + + if (maxAssociations === 0) { + maxAssociations = _countChoices(interaction); + } + + if (_.size(currentResponse) > maxAssociations) { + // No more associations possible. + e.preventDefault(); + instructionMgr.validateInstructions(interaction); + } else if ((choice = _maxMatchReached(interaction, e.target)) !== false) { + // Check if matchmax is respected for both choices + // involved in the selection. + e.preventDefault(); + instructionMgr.validateInstructions(interaction, choice); + } else { + containerHelper.triggerResponseChangeEvent(interaction, {}); + instructionMgr.validateInstructions(interaction); + } +}; + +var _maxMatchReached = function(interaction, input) { + var association = _inferValue(input); + var overflow = false; + + _(association).forEach(function(identifier) { + var choice = _getChoiceDefinitionByIdentifier(interaction, identifier); + var matchMin = choice.attributes.matchMin; + var matchMax = choice.attributes.matchMax; + var assoc = _countAssociations(interaction, choice); + + if (matchMax > 0 && assoc > matchMax) { + overflow = choice; + } + }); + + return overflow; +}; + +var _countAssociations = function(interaction, choice) { + var rawResponse = _getRawResponse(interaction); + var count = 0; + + // How much time can we find rawChoice in rawResponses? + _(rawResponse).forEach(function(response) { + if (response[0] === choice.attributes.identifier || response[1] === choice.attributes.identifier) { + count++; + } + }); + + return count; +}; + +var _countChoices = function(interaction) { + var $container = containerHelper.get(interaction); + return $container.find('input[type=checkbox]').length; +}; + +var _getChoiceDefinitionByIdentifier = function(interaction, identifier) { + var rawChoices = _getRawChoices(interaction); + return rawChoices[identifier]; +}; + +var _getRawChoices = function(interaction) { + var rawChoices = {}; + + _(interaction.choices).forEach(function(matchset) { + _(matchset).forEach(function(choice) { + rawChoices[choice.attributes.identifier] = choice; + }); + }); + + return rawChoices; +}; + +var _setInstructions = function(interaction) { + var msg; + var minAssociations = interaction.attr('minAssociations'); + var maxAssociations = interaction.attr('maxAssociations'); + var choiceCount = _countChoices(interaction); + + // Super closure is here again to save our souls! Houray! + // ~~~~~~~ |==||||0__ + + var superClosure = function() { + var onMaxChoicesReached = function(report, msg) { + if (inWarning === false) { + inWarning = true; + + report.update({ + level: 'warning', + message: __('Maximum number of choices reached.'), + timeout: 2000, + stop: function() { + report.update({ level: 'success', message: msg }); + inWarning = false; + } + }); + } + }; + + var onMatchMaxReached = function(interaction, choice, report, msg, level) { + var $container = containerHelper.get(interaction); + + if (inWarning === false) { + inWarning = true; + + var $choice = $container.find( + '.qti-simpleAssociableChoice[data-identifier="' + choice.attributes.identifier + '"]' + ); + var originalBackgroundColor = $choice.css('background-color'); + var originalColor = $choice.css('color'); + + report.update({ + level: 'warning', + message: __('The highlighted choice cannot be associated more than %d time(s).').replace( + '%d', + choice.attributes.matchMax + ), + timeout: 3000, + start: function() { + $choice.animate( + { + backgroundColor: '#fff', + color: '#ba122b' + }, + 250, + function() { + $choice.animate( + { + backgroundColor: '#ba122b', + color: '#fff' + }, + 250 + ); + } + ); + }, + stop: function() { + $choice.animate( + { + backgroundColor: originalBackgroundColor, + color: originalColor + }, + 500 + ); + report.update({ level: level, message: msg }); + inWarning = false; + } + }); + } + }; + + if (minAssociations === 0 && maxAssociations > 0) { + // No minimum but maximum. + msg = __('You must select 0 to %d choices.').replace('%d', maxAssociations); + + instructionMgr.appendInstruction(interaction, msg, function(choice) { + var responseCount = _.size(_getRawResponse(interaction)); + + if ( + choice && + choice.attributes && + choice.attributes.matchMax > 0 && + _countAssociations(interaction, choice) > choice.attributes.matchMax + ) { + onMatchMaxReached(interaction, choice, this, msg, this.getLevel()); + } else if (responseCount <= maxAssociations) { + this.setLevel('success'); + } else if (responseCount > maxAssociations) { + onMaxChoicesReached(this, msg); + } else { + this.reset(); + } + }); + } else if (minAssociations === 0 && maxAssociations === 0) { + // No minimum, no maximum. + msg = __('You must select 0 to %d choices.').replace('%d', choiceCount); + + instructionMgr.appendInstruction(interaction, msg, function(choice) { + if ( + choice && + choice.attributes && + choice.attributes.matchMax > 0 && + _countAssociations(interaction, choice) > choice.attributes.matchMax + ) { + onMatchMaxReached(interaction, choice, this, msg, this.getLevel()); + } else { + this.setLevel('success'); + } + }); + } else if (minAssociations > 0 && maxAssociations === 0) { + // minimum but no maximum. + msg = __('You must select %1$d to %2$d choices.'); + msg = msg.replace('%1$d', minAssociations); + msg = msg.replace('%2$d', choiceCount); + + instructionMgr.appendInstruction(interaction, msg, function(choice) { + var responseCount = _.size(_getRawResponse(interaction)); + + if ( + choice && + choice.attributes && + choice.attributes.matchMax > 0 && + _countAssociations(interaction, choice) > choice.attributes.matchMax + ) { + onMatchMaxReached(interaction, choice, this, msg, this.getLevel()); + } else if (responseCount < minAssociations) { + this.setLevel('info'); + } else if (responseCount > choiceCount) { + onMaxChoicesReached(this, msg); + } else { + this.setLevel('success'); + } + }); + } else if (minAssociations > 0 && maxAssociations > 0) { + // minimum and maximum. + if (minAssociations !== maxAssociations) { + msg = __('You must select %1$d to %2$d choices.'); + msg = msg.replace('%1$d', minAssociations); + msg = msg.replace('%2$d', maxAssociations); + } else { + msg = __('You must select exactly %d choice(s).'); + msg = msg.replace('%d', minAssociations); + } + + instructionMgr.appendInstruction(interaction, msg, function(choice) { + var responseCount = _.size(_getRawResponse(interaction)); + + if ( + choice && + choice.attributes && + choice.attributes.matchMax > 0 && + _countAssociations(interaction, choice) > choice.attributes.matchMax + ) { + onMatchMaxReached(interaction, choice, this, msg, this.getLevel()); + } else if (responseCount < minAssociations) { + this.setLevel('info'); + } else if (responseCount > maxAssociations) { + onMaxChoicesReached(this, msg); + } else if (responseCount >= minAssociations && responseCount <= maxAssociations) { + this.setLevel('success'); + } + }); + } + }; + + superClosure(); +}; + +var destroy = function(interaction) { + var $container = containerHelper.get(interaction); + $container.off('.commonRenderer'); + + instructionMgr.removeInstructions(interaction); + + //remove all references to a cache container + containerHelper.reset(interaction); +}; + +/** + * Set the interaction state. It could be done anytime with any state. + * + * @param {Object} interaction - the interaction instance + * @param {Object} state - the interaction state + */ +var setState = function setState(interaction, state) { + var $container; + + if (_.isObject(state)) { + //restore order of previously shuffled choices + if (_.isArray(state.order) && state.order.length === 2) { + $container = containerHelper.get(interaction); + + $('thead .qti-choice', $container) + .sort(function(a, b) { + var aIndex = _.indexOf(state.order[0], $(a).data('identifier')); + var bIndex = _.indexOf(state.order[0], $(b).data('identifier')); + if (aIndex > bIndex) { + return 1; + } + if (aIndex < bIndex) { + return -1; + } + return 0; + }) + .detach() + .appendTo($('thead tr', $container)); + + $('tbody .qti-choice', $container) + .sort(function(a, b) { + var aIndex = _.indexOf(state.order[1], $(a).data('identifier')); + var bIndex = _.indexOf(state.order[1], $(b).data('identifier')); + if (aIndex > bIndex) { + return 1; + } + if (aIndex < bIndex) { + return -1; + } + return 0; + }) + .detach() + .each(function(index, elt) { + $(elt).prependTo($('tbody tr', $container).eq(index)); + }); + } + + if (state.response) { + interaction.resetResponse(); + interaction.setResponse(state.response); + } + } +}; + +/** + * Get the interaction state. + * + * @param {Object} interaction - the interaction instance + * @returns {Object} the interaction current state + */ +var getState = function getState(interaction) { + var $container; + var state = {}; + var response = interaction.getResponse(); + + if (response) { + state.response = response; + } + + //we store also the choice order if shuffled + if (interaction.attr('shuffle') === true) { + $container = containerHelper.get(interaction); + + state.order = [[], []]; + $('thead .qti-choice', $container).each(function() { + state.order[0].push($(this).data('identifier')); + }); + $('tbody .qti-choice', $container).each(function() { + state.order[1].push($(this).data('identifier')); + }); + } + return state; +}; + +/** + * Expose the common renderer for the match interaction + * @exports qtiCommonRenderer/renderers/interactions/MatchInteraction + */ +export default { + qtiClass: 'matchInteraction', + template: tpl, + render: render, + getContainer: containerHelper.get, + setResponse: setResponse, + getResponse: getResponse, + resetResponse: resetResponse, + destroy: destroy, + setState: setState, + getState: getState, + inferValue: _inferValue +}; diff --git a/src/qtiCommonRenderer/renderers/interactions/MediaInteraction.js b/src/qtiCommonRenderer/renderers/interactions/MediaInteraction.js new file mode 100644 index 00000000..f25f6a0c --- /dev/null +++ b/src/qtiCommonRenderer/renderers/interactions/MediaInteraction.js @@ -0,0 +1,360 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014-2018 (original work) Open Assessment Technlogies SA + * + */ + +/** + * Common renderer for the QTI media interaction. + * + * @author Bertrand Chevrier + */ +import $ from 'jquery'; +import _ from 'lodash'; +import Promise from 'core/promise'; +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/mediaInteraction'; +import pciResponse from 'taoQtiItem/qtiCommonRenderer/helpers/PciResponse'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; +import mediaplayer from 'ui/mediaplayer'; + +//some default values +var defaults = { + type: 'video/mp4', + video: { + width: 480, + height: 270 + }, + audio: { + width: 400, + height: 30 + } +}; + +/** + * Init rendering, called after template injected into the DOM + * All options are listed in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10391 + * + * @param {object} interaction + * @fires playerrendered when the player is at least rendered + * @fires playerready when the player is sucessfully loaded and configured + */ +var render = function render(interaction) { + var self = this; + return new Promise(function(resolve) { + var $container = containerHelper.get(interaction); + var media = interaction.object; + var $item = $container.parents('.qti-item'); + var maxPlays = parseInt(interaction.attr('maxPlays'), 10) || 0; + var url = media.attr('data') || ''; + + //check if the media can be played (using timesPlayed and maxPlays) + var canBePlayed = function canBePlayed() { + var current = parseInt($container.data('timesPlayed'), 10); + return maxPlays === 0 || maxPlays > current; + }; + + /** + * Resize video player elements to fit container size + * @param {Object} mediaElement - player instance + * @param {jQueryElement} $container - container element to adapt + */ + var resize = _.debounce(function resize() { + var width, height; + if (interaction.mediaElement) { + height = $container.find('.media-container').height(); + width = $container.find('.media-container').width(); + + interaction.mediaElement.resize(width, height); + } + }, 200); + + //intialize the player if not yet done + var initMediaPlayer = function initMediaPlayer() { + if (!interaction.mediaElement) { + interaction.mediaElement = mediaplayer({ + url: url && self.resolveUrl(url), + type: media.attr('type') || defaults.type, + canPause: $container.hasClass('pause'), + maxPlays: maxPlays, + canSeek: !maxPlays, + width: media.attr('width'), + height: media.attr('height'), + volume: 100, + autoStart: !!interaction.attr('autostart') && canBePlayed(), + loop: !!interaction.attr('loop'), + renderTo: $('.media-container', $container) + }) + .on('render', function() { + resize(); + + $(window) + .off('resize.mediaInteraction') + .on('resize.mediaInteraction', resize); + + $item.off('resize.gridEdit').on('resize.gridEdit', resize); + + /** + * @event playerrendered + */ + $container.trigger('playerrendered'); + }) + .on('ready', function() { + /** + * @event playerready + */ + $container.trigger('playerready'); + + if (!canBePlayed()) { + this.disable(); + } + + // declare the item ready when player is ready to play. + resolve(); + }) + .on( + 'update', + _.throttle(function() { + containerHelper.triggerResponseChangeEvent(interaction); + }, 1000) + ) + .on('ended', function() { + $container.data('timesPlayed', $container.data('timesPlayed') + 1); + containerHelper.triggerResponseChangeEvent(interaction); + + if (!canBePlayed()) { + this.disable(); + } + }); + } + }; + + if (_.size(media.attributes) === 0) { + //TODO move to afterCreate + media.attr('type', defaults.type); + media.attr('width', $container.innerWidth()); + + media.attr('height', defaults.video.height); + media.attr('data', ''); + } + + //set up the number of times played + if (!$container.data('timesPlayed')) { + $container.data('timesPlayed', 0); + } + + //initialize the component + $container.on('responseSet', function() { + initMediaPlayer(); + }); + + //gives a small chance to the responseSet event before initializing the player + initMediaPlayer(); + }); +}; + +/** + * Destroy the current interaction + * @param {Object} interaction + */ +var destroy = function(interaction) { + var $container = containerHelper.get(interaction); + + if (interaction.mediaElement) { + interaction.mediaElement.destroy(); + interaction.mediaElement = null; + } + + $('.instruction-container', $container).empty(); + $('.media-container', $container).empty(); + + $container.removeData('timesPlayed'); + + $(window).off('resize.video'); + + //remove all references to a cache container + containerHelper.reset(interaction); +}; + +/** + * Get the responses from the interaction + * @private + * @param {Object} interaction + * @returns {Array} of points + */ +var _getRawResponse = function _getRawResponse(interaction) { + return [containerHelper.get(interaction).data('timesPlayed') || 0]; +}; + +/** + * Set the response to the rendered interaction. + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 + * + * Special value: the empty object value {} resets the interaction responses + * + * @param {object} interaction + * @param {object} response + */ +var setResponse = function(interaction, response) { + var responseValues; + if (response) { + try { + //try to unserialize the pci response + responseValues = pciResponse.unserialize(response, interaction); + containerHelper.get(interaction).data('timesPlayed', responseValues[0]); + } catch (e) { + // something went wrong + } + } +}; + +/** + * Reset the current responses of the rendered interaction. + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 + * + * Special value: the empty object value {} resets the interaction responses + * + * @param {object} interaction + * @param {object} response + */ +var resetResponse = function resetResponse(interaction) { + containerHelper.get(interaction).data('timesPlayed', 0); +}; + +/** + * Return the response of the rendered interaction + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 + * + * @param {object} interaction + * @returns {object} + */ +var getResponse = function(interaction) { + return pciResponse.serialize(_getRawResponse(interaction), interaction); +}; + +/** + * Set the interaction state. It could be done anytime with any state. + * + * @param {Object} interaction - the interaction instance + * @param {Object} state - the interaction state + */ +var setState = function setState(interaction, state) { + /** + * Restore the media player state + * @private + * @param {Object} [state] + * @param {Boolean} [state.muted] - is the player muted + * @param {Number} [state.volume] - the current volume + * @param {Number} [state.position] - the position to seek to + */ + var restorePlayerState = function restorePlayerState(playerState) { + if (playerState && interaction.mediaElement) { + //Volume + if (_.isNumber(playerState.volume)) { + interaction.mediaElement.setVolume(playerState.volume); + } + + //Muted state (always after the volume) + if (_.isBoolean(playerState.muted)) { + interaction.mediaElement.mute(playerState.muted); + interaction.mediaElement.startMuted = playerState.muted; + } + + //Position + if (playerState.position && playerState.position > 0) { + interaction.mediaElement.seek(playerState.position); + if (!interaction.attr('autostart')) { + interaction.mediaElement.pause(); + } + } + } + }; + + if (_.isObject(state)) { + if (state.response) { + interaction.resetResponse(); + interaction.setResponse(state.response); + } + + if (_.isPlainObject(state.player) && interaction.mediaElement) { + if (interaction.mediaElement.is('ready')) { + restorePlayerState(state.player); + } else { + interaction.mediaElement.on('ready.state', function() { + interaction.mediaElement.off('ready.state'); + restorePlayerState(state.player); + }); + } + } + } +}; + +/** + * Get the interaction state. + * + * @param {Object} interaction - the interaction instance + * @returns {Object} the interaction current state + */ +var getState = function getState(interaction) { + var state = {}; + var response = interaction.getResponse(); + + if (response) { + state.response = response; + } + + //collect player's state + if (interaction.mediaElement) { + state.player = { + position: interaction.mediaElement.getPosition(), + muted: interaction.mediaElement.is('muted'), + volume: interaction.mediaElement.getVolume() + }; + } + return state; +}; + +/** + * Expose the common renderer for the interaction + * @exports qtiCommonRenderer/renderers/interactions/MediaInteraction + */ +export default { + qtiClass: 'mediaInteraction', + template: tpl, + render: render, + getContainer: containerHelper.get, + setResponse: setResponse, + getResponse: getResponse, + resetResponse: resetResponse, + destroy: destroy, + setState: setState, + getState: getState +}; diff --git a/src/qtiCommonRenderer/renderers/interactions/OrderInteraction.js b/src/qtiCommonRenderer/renderers/interactions/OrderInteraction.js new file mode 100644 index 00000000..217123f5 --- /dev/null +++ b/src/qtiCommonRenderer/renderers/interactions/OrderInteraction.js @@ -0,0 +1,727 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014-2019 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ + +/** + * @author Sam Sipasseuth + * @author Bertrand Chevrier + */ +import _ from 'lodash'; +import $ from 'jquery'; +import __ from 'i18n'; +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/orderInteraction'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; +import instructionMgr from 'taoQtiItem/qtiCommonRenderer/helpers/instructions/instructionManager'; +import pciResponse from 'taoQtiItem/qtiCommonRenderer/helpers/PciResponse'; +import interact from 'interact'; +import interactUtils from 'ui/interactUtils'; + +/** + * Init rendering, called after template injected into the DOM + * All options are listed in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10283 + * + * @param {Object} interaction - the interaction instance + */ +var render = function(interaction) { + var $container = containerHelper.get(interaction), + $choiceArea = $container.find('.choice-area'), + $resultArea = $container.find('.result-area'), + $iconAdd = $container.find('.icon-add-to-selection'), + $iconRemove = $container.find('.icon-remove-from-selection'), + $iconBefore = $container.find('.icon-move-before'), + $iconAfter = $container.find('.icon-move-after'), + $activeChoice = null, + choiceSelector = $choiceArea.selector + ' >li:not(.deactivated)', + resultSelector = $resultArea.selector + ' >li', + scaleX, + scaleY, + isDragAndDropEnabled, + dragOptions, + $dropzoneElement, + $dragContainer = $container.find('.drag-container'), + orientation = interaction.attr('orientation') ? interaction.attr('orientation') : 'vertical'; + + var _activeControls = function _activeControls() { + $iconAdd.addClass('inactive'); + $iconRemove.removeClass('inactive').addClass('active'); + $iconBefore.removeClass('inactive').addClass('active'); + $iconAfter.removeClass('inactive').addClass('active'); + }; + + var _resetControls = function _resetControls() { + $iconAdd.removeClass('inactive'); + $iconRemove.removeClass('active').addClass('inactive'); + $iconBefore.removeClass('active').addClass('inactive'); + $iconAfter.removeClass('active').addClass('inactive'); + }; + + var _setSelection = function _setSelection($choice) { + if ($activeChoice) { + $activeChoice.removeClass('active'); + } + $activeChoice = $choice; + $activeChoice.addClass('active'); + }; + + var _resetSelection = function _resetSelection() { + if ($activeChoice) { + $activeChoice.removeClass('active'); + $activeChoice = null; + } + _resetControls(); + }; + + var _addChoiceToSelection = function _addChoiceToSelection($target, position) { + var $results = $(resultSelector); + _resetSelection(); + + //move choice to the result list: + if (typeof position !== 'undefined' && position < $results.length) { + $results.eq(position).before($target); + } else { + $resultArea.append($target); + } + + containerHelper.triggerResponseChangeEvent(interaction); + + //update constraints : + instructionMgr.validateInstructions(interaction); + }; + + var _toggleResultSelection = function _toggleResultSelection($target) { + if ($target.hasClass('active')) { + _resetSelection(); + } else { + _setSelection($target); + _activeControls(); + } + }; + + var _removeChoice = function _removeChoice() { + if ($activeChoice) { + //restore choice back to choice list + $choiceArea.append($activeChoice); + containerHelper.triggerResponseChangeEvent(interaction); + + //update constraints : + instructionMgr.validateInstructions(interaction); + } + + _resetSelection(); + }; + + var _moveResultBefore = function _moveResultBefore() { + var $prev = $activeChoice.prev(); + + if ($prev.length) { + $prev.before($activeChoice); + containerHelper.triggerResponseChangeEvent(interaction); + } + }; + + var _moveResultAfter = function _moveResultAfter() { + var $next = $activeChoice.next(); + + if ($next.length) { + $next.after($activeChoice); + containerHelper.triggerResponseChangeEvent(interaction); + } + }; + + // Point & click handlers + + interact($container.selector).on('tap', function() { + _resetSelection(); + }); + + interact(choiceSelector).on('tap', function(e) { + var $target = $(e.currentTarget); + + //if tts component is loaded and click-to-speak function is activated - we should prevent this listener to go further + if ($target.closest('.qti-item').hasClass('prevent-click-handler')) { + return; + } + + e.stopPropagation(); + + $iconAdd.addClass('triggered'); + setTimeout(function() { + $iconAdd.removeClass('triggered'); + }, 150); + + _addChoiceToSelection($target); + }); + + interact(resultSelector).on('tap', function(e) { + var $target = $(e.currentTarget); + + //if tts component is loaded and click-to-speak function is activated - we should prevent this listener to go further + if ($target.closest('.qti-item').hasClass('prevent-click-handler')) { + return; + } + + e.stopPropagation(); + _toggleResultSelection($target); + }); + + interact($iconRemove.selector).on('tap', function(e) { + //if tts component is loaded and click-to-speak function is activated - we should prevent this listener to go further + if ( + $(e.currentTarget) + .closest('.qti-item') + .hasClass('prevent-click-handler') + ) { + return; + } + + e.stopPropagation(); + _removeChoice(); + }); + + interact($iconBefore.selector).on('tap', function(e) { + //if tts component is loaded and click-to-speak function is activated - we should prevent this listener to go further + if ( + $(e.currentTarget) + .closest('.qti-item') + .hasClass('prevent-click-handler') + ) { + return; + } + + e.stopPropagation(); + _moveResultBefore(); + }); + + interact($iconAfter.selector).on('tap', function(e) { + //if tts component is loaded and click-to-speak function is activated - we should prevent this listener to go further + if ( + $(e.currentTarget) + .closest('.qti-item') + .hasClass('prevent-click-handler') + ) { + return; + } + + e.stopPropagation(); + _moveResultAfter(); + }); + + // Drag & drop handlers + + if (this.getOption && this.getOption('enableDragAndDrop') && this.getOption('enableDragAndDrop').order) { + isDragAndDropEnabled = this.getOption('enableDragAndDrop').order; + } + + function _iFrameDragFix(draggableSelector, target) { + interactUtils.iFrameDragFixOn(function() { + if (_isDropzoneVisible()) { + interact($resultArea.selector).fire({ + type: 'drop', + target: $dropzoneElement.eq(0), + relatedTarget: target + }); + } + interact(draggableSelector).fire({ + type: 'dragend', + target: target + }); + }); + } + + if (isDragAndDropEnabled) { + $dropzoneElement = $('
  • ', { class: 'dropzone qti-choice' }); + $('
    ', { class: 'qti-block' }).appendTo($dropzoneElement); + + dragOptions = { + inertia: false, + autoScroll: true, + restrict: { + restriction: '.qti-interaction', + endOnly: false, + elementRect: { top: 0, left: 0, bottom: 1, right: 1 } + } + }; + + // makes choices draggables + interact(choiceSelector) + .draggable( + _.assign({}, dragOptions, { + onstart: function(e) { + var $target = $(e.target); + var scale; + $target.addClass('dragged'); + + _iFrameDragFix(choiceSelector, e.target); + scale = interactUtils.calculateScale(e.target); + scaleX = scale[0]; + scaleY = scale[1]; + }, + onmove: function(e) { + var $target = $(e.target); + interactUtils.moveElement(e.target, e.dx / scaleX, e.dy / scaleY); + if (_isDropzoneVisible()) { + _adjustDropzonePosition($target); + } + }, + onend: function(e) { + var $target = $(e.target); + $target.removeClass('dragged'); + + interactUtils.restoreOriginalPosition($target); + interactUtils.iFrameDragFixOff(); + } + }) + ) + .styleCursor(false); + + // makes result draggables + interact(resultSelector) + .draggable( + _.assign({}, dragOptions, { + onstart: function(e) { + var $target = $(e.target); + var scale; + $target.addClass('dragged'); + + _setSelection($target); + + // move dragged result to drag container + $dragContainer.show(); + $dragContainer.offset($target.offset()); + if (orientation === 'horizontal') { + $dragContainer.width($(e.currentTarget).width()); + } else { + $dragContainer.width($target.parent().width()); + } + $dragContainer.append($target); + + _iFrameDragFix(resultSelector, e.target); + scale = interactUtils.calculateScale(e.target); + scaleX = scale[0]; + scaleY = scale[1]; + }, + onmove: function(e) { + var $target = $(e.target); + interactUtils.moveElement(e.target, e.dx / scaleX, e.dy / scaleY); + if (_isDropzoneVisible()) { + _adjustDropzonePosition($target); + } + }, + onend: function(e) { + var $target = $(e.target), + hasBeenDroppedInResultArea = $target.parent === $resultArea; + + $target.removeClass('dragged'); + $dragContainer.hide(); + + if (!hasBeenDroppedInResultArea) { + _removeChoice(); + } + + interactUtils.restoreOriginalPosition($target); + interactUtils.iFrameDragFixOff(); + } + }) + ) + .styleCursor(false); + + // makes result area droppable + interact($resultArea.selector).dropzone({ + overlap: 0.5, + ondragenter: function(e) { + var $dragged = $(e.relatedTarget); + _insertDropzone($dragged); + $dragged.addClass('droppable'); + }, + ondrop: function(e) { + var $dragged = $(e.relatedTarget), + dropzoneIndex = $(resultSelector).index($dropzoneElement); + + this.ondragleave(e); + + _addChoiceToSelection($dragged, dropzoneIndex); + interactUtils.restoreOriginalPosition($dragged); + }, + ondragleave: function(e) { + var $dragged = $(e.relatedTarget); + $dropzoneElement.remove(); + $dragged.removeClass('droppable'); + } + }); + } + + function _isDropzoneVisible() { + return $.contains($container.get(0), $dropzoneElement.get(0)); + } + + function _insertDropzone($dragged) { + var draggedMiddle = _getMiddleOf($dragged), + previousMiddle = { + x: 0, + y: 0 + }, + insertPosition, + $dropzone; + + // look for position where to insert dropzone + $(resultSelector).each(function(index) { + var currentMiddle = _getMiddleOf($(this)); + + if (orientation !== 'horizontal') { + if (draggedMiddle.y > previousMiddle.y && draggedMiddle.y < currentMiddle.y) { + insertPosition = index; + return false; + } + previousMiddle.y = currentMiddle.y; + } else { + if (draggedMiddle.x > previousMiddle.x && draggedMiddle.x < currentMiddle.x) { + insertPosition = index; + return false; + } + previousMiddle.x = currentMiddle.x; + } + }); + // append dropzone to DOM + if (typeof insertPosition !== 'undefined') { + $(resultSelector) + .eq(insertPosition) + .before($dropzoneElement); + } else { + // no index found, we just append to the end + $resultArea.append($dropzoneElement); + } + + // style dropzone + $dropzoneElement.height($dragged.height()); + $dropzoneElement.find('div').text($dragged.text()); + } + + function _adjustDropzonePosition($dragged) { + var draggedBox = $dragged.get(0).getBoundingClientRect(), + $prevResult = $dropzoneElement.prev('.qti-choice'), + $nextResult = $dropzoneElement.next('.qti-choice'), + prevMiddle = $prevResult.length > 0 ? _getMiddleOf($prevResult) : false, + nextMiddle = $nextResult.length > 0 ? _getMiddleOf($nextResult) : false; + + if (orientation !== 'horizontal') { + if (prevMiddle && draggedBox.top < prevMiddle.y) { + $prevResult.before($dropzoneElement); + } + if (nextMiddle && draggedBox.bottom > nextMiddle.y) { + $nextResult.after($dropzoneElement); + } + } else { + if (prevMiddle && draggedBox.left < prevMiddle.x) { + $prevResult.before($dropzoneElement); + } + if (nextMiddle && draggedBox.right > nextMiddle.x) { + $nextResult.after($dropzoneElement); + } + } + } + + function _getMiddleOf($element) { + var elementBox = $element.get(0).getBoundingClientRect(); + return { + x: elementBox.left + elementBox.width / 2, + y: elementBox.top + elementBox.height / 2 + }; + } + + // rendering init + + _setInstructions(interaction); + + //bind event listener in case the attributes change dynamically on runtime + $(document).on('attributeChange.qti-widget.commonRenderer', function(e, data) { + if (data.element.getSerial() === interaction.getSerial()) { + if (data.key === 'maxChoices' || data.key === 'minChoices') { + instructionMgr.removeInstructions(interaction); + _setInstructions(interaction); + instructionMgr.validateInstructions(interaction); + } + } + }); + + _freezeSize($container); +}; + +var _freezeSize = function($container) { + var $orderArea = $container.find('.order-interaction-area'); + $orderArea.height($orderArea.height()); +}; + +var _setInstructions = function(interaction) { + var $container = containerHelper.get(interaction); + var $choiceArea = $('.choice-area', $container), + $resultArea = $('.result-area', $container), + min = parseInt(interaction.attr('minChoices'), 10), + max = parseInt(interaction.attr('maxChoices'), 10); + + if (min) { + instructionMgr.appendInstruction(interaction, __('You must use at least %d choices', min), function() { + if ($resultArea.find('>li').length >= min) { + this.setLevel('success'); + } else { + this.reset(); + } + }); + } + + if (max && max < _.size(interaction.getChoices())) { + var instructionMax = instructionMgr.appendInstruction( + interaction, + __('You can use maximum %d choices', max), + function() { + if ($resultArea.find('>li').length >= max) { + $choiceArea.find('>li').addClass('deactivated'); + this.setMessage(__('Maximum choices reached')); + } else { + $choiceArea.find('>li').removeClass('deactivated'); + this.reset(); + } + } + ); + + interact($choiceArea.selector + ' >li.deactivated').on('tap', function(e) { + var $target = $(e.currentTarget); + $target.addClass('brd-error'); + instructionMax.setLevel('warning', 2000); + setTimeout(function() { + $target.removeClass('brd-error'); + }, 150); + }); + + // we don't check for isDragAndDropEnabled on purpose, as this binding is not to allow dragging, + // but only to provide feedback in case of a drag action on an inactive choice + interact($choiceArea.selector + ' >li.deactivated') + .draggable({ + onstart: function(e) { + var $target = $(e.target); + $target.addClass('brd-error'); + instructionMax.setLevel('warning'); + }, + onend: function(e) { + var $target = $(e.target); + $target.removeClass('brd-error'); + instructionMax.setLevel('info'); + } + }) + .styleCursor(false); + } +}; + +var resetResponse = function(interaction) { + var $container = containerHelper.get(interaction); + var initialOrder = _.keys(interaction.getChoices()); + var $choiceArea = $('.choice-area', $container).append($('.result-area>li', $container)); + var $choices = $choiceArea.children('.qti-choice'); + + $container.find('.qti-choice.active').each(function deactivateChoice() { + interactUtils.tapOn(this); + }); + + $choices.detach().sort(function(choice1, choice2) { + return _.indexOf(initialOrder, $(choice1).data('serial')) > _.indexOf(initialOrder, $(choice2).data('serial')); + }); + $choiceArea.prepend($choices); +}; + +/** + * Set the response to the rendered interaction. + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10283 + * + * Special value: the empty object value {} resets the interaction responses + * + * @param {object} interaction + * @param {object} response + */ +var setResponse = function(interaction, response) { + var $container = containerHelper.get(interaction); + var $choiceArea = $('.choice-area', $container); + var $resultArea = $('.result-area', $container); + + if (response === null || _.isEmpty(response)) { + resetResponse(interaction); + } else { + try { + _.each(pciResponse.unserialize(response, interaction), function(identifier) { + $resultArea.append($choiceArea.find('[data-identifier=' + identifier + ']')); + }); + } catch (e) { + throw new Error('wrong response format in argument : ' + e); + } + } + + instructionMgr.validateInstructions(interaction); +}; + +var _getRawResponse = function(interaction) { + var $container = containerHelper.get(interaction); + var response = []; + $('.result-area>li', $container).each(function() { + response.push($(this).data('identifier')); + }); + return response; +}; + +/** + * Return the response of the rendered interaction + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10283 + * + * @param {object} interaction + * @returns {object} + */ +var getResponse = function(interaction) { + return pciResponse.serialize(_getRawResponse(interaction), interaction); +}; + +/** + * Set additionnal data to the template (data that are not really part of the model). + * @param {Object} interaction - the interaction + * @param {Object} [data] - interaction custom data + * @returns {Object} custom data + */ +var getCustomData = function(interaction, data) { + return _.merge(data || {}, { + horizontal: interaction.attr('orientation') === 'horizontal' + }); +}; + +/** + * Destroy the interaction by leaving the DOM exactly in the same state it was before loading the interaction. + * @param {Object} interaction - the interaction + */ +var destroy = function(interaction) { + var $container = containerHelper.get(interaction); + + //first, remove all events + var selectors = [ + '.choice-area >li:not(.deactivated)', + '.result-area >li', + '.icon-add-to-selection', + '.icon-remove-from-selection', + '.icon-move-before', + '.icon-move-after' + ]; + selectors.forEach(function unbindInteractEvents(selector) { + interact($container.find(selector).selector).unset(); + }); + + $(document).off('.commonRenderer'); + + $container.find('.order-interaction-area').removeAttr('style'); + + instructionMgr.removeInstructions(interaction); + + //remove all references to a cache container + containerHelper.reset(interaction); +}; + +/** + * Set the interaction state. It could be done anytime with any state. + * + * @param {Object} interaction - the interaction instance + * @param {Object} state - the interaction state + */ +var setState = function setState(interaction, state) { + var $container; + + if (_.isObject(state)) { + if (state.response) { + interaction.resetResponse(); + interaction.setResponse(state.response); + } + + //restore order of previously shuffled choices + if (_.isArray(state.order) && state.order.length === _.size(interaction.getChoices())) { + $container = containerHelper.get(interaction); + + $('.choice-area .qti-choice', $container) + .sort(function(a, b) { + var aIndex = _.indexOf(state.order, $(a).data('identifier')); + var bIndex = _.indexOf(state.order, $(b).data('identifier')); + if (aIndex > bIndex) { + return 1; + } + if (aIndex < bIndex) { + return -1; + } + return 0; + }) + .detach() + .appendTo($('.choice-area', $container)); + } + } +}; + +/** + * Get the interaction state. + * + * @param {Object} interaction - the interaction instance + * @returns {Object} the interaction current state + */ +var getState = function getState(interaction) { + var $container; + var state = {}; + var response = interaction.getResponse(); + + if (response) { + state.response = response; + } + + //we store also the choice order if shuffled + if (interaction.attr('shuffle') === true) { + $container = containerHelper.get(interaction); + + state.order = []; + $('.choice-area .qti-choice', $container).each(function() { + state.order.push($(this).data('identifier')); + }); + } + return state; +}; + +/** + * Expose the common renderer for the order interaction + * @exports qtiCommonRenderer/renderers/interactions/OrderInteraction + */ +export default { + qtiClass: 'orderInteraction', + getData: getCustomData, + template: tpl, + render: render, + getContainer: containerHelper.get, + setResponse: setResponse, + getResponse: getResponse, + resetResponse: resetResponse, + destroy: destroy, + setState: setState, + getState: getState +}; diff --git a/src/qtiCommonRenderer/renderers/interactions/PortableCustomInteraction.js b/src/qtiCommonRenderer/renderers/interactions/PortableCustomInteraction.js new file mode 100644 index 00000000..3fc2935e --- /dev/null +++ b/src/qtiCommonRenderer/renderers/interactions/PortableCustomInteraction.js @@ -0,0 +1,203 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014-2017 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + * + */ + +/** + * @author Sam Sipasseuth + * @author Bertrand Chevrier + */ +import _ from 'lodash'; +import Promise from 'core/promise'; +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/customInteraction'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; +import PortableElement from 'taoQtiItem/qtiCommonRenderer/helpers/PortableElement'; +import instanciator from 'taoQtiItem/qtiCommonRenderer/renderers/interactions/pci/instanciator'; +import commonPciRenderer from 'taoQtiItem/qtiCommonRenderer/renderers/interactions/pci/common'; +import imsPciRenderer from 'taoQtiItem/qtiCommonRenderer/renderers/interactions/pci/ims'; +import util from 'taoQtiItem/qtiItem/helper/util'; +import ciRegistry from 'taoQtiItem/portableElementRegistry/ciRegistry'; + +var _setPciModel = function _setPciModel(interaction, runtime) { + var pciRenderer; + if (runtime.model === 'IMSPCI') { + pciRenderer = imsPciRenderer(runtime); + } else { + pciRenderer = commonPciRenderer(runtime); + } + interaction.data('pci-model', runtime.model); + interaction.data('pci-renderer', pciRenderer); +}; + +var _getPciRenderer = function _getPciRenderer(interaction) { + return interaction.data('pci-renderer'); +}; + +/** + * Execute javascript codes to bring the interaction to life. + * At this point, the html markup must already be ready in the document. + * + * It is done in 5 steps : + * 1. configure the paths + * 2. require all required libs + * 3. create a pci instance based on the interaction model + * 4. initialize the rendering + * 5. restore full state if applicable (state and/or response) + * + * @param {Object} interaction + */ +var render = function render(interaction, options) { + var self = this; + + options = options || {}; + return new Promise(function(resolve, reject) { + var id = interaction.attr('responseIdentifier'); + var typeIdentifier = interaction.typeIdentifier; + var assetManager = self.getAssetManager(); + var state; + var response = {}; + + if (options.state && options.state[id]) { + state = options.state[id]; + } + response[id] = { base: null }; + + ciRegistry + .loadRuntimes({ include: [typeIdentifier] }) + .then(function() { + var pciRenderer; + var runtime = ciRegistry.getRuntime(typeIdentifier); + + if (!runtime) { + return reject('The runtime for the pci cannot be found : ' + typeIdentifier); + } + + _setPciModel(interaction, runtime); + + pciRenderer = _getPciRenderer(interaction); + + require(pciRenderer.getRequiredModules(), function() { + var pci = instanciator.getPci(interaction); + if (pci) { + pciRenderer.createInstance(interaction, { + response: response, + state: state, + assetManager: assetManager + }); + //forward internal PCI event responseChange + if (_.isFunction(pci.on)) { + interaction.onPci('responseChange', function() { + containerHelper.triggerResponseChangeEvent(interaction); + }); + } + return resolve(); + } + return reject('Unable to initialize pci "' + id + '"'); + }, reject); + }) + .catch(function(error) { + reject('Error loading runtime "' + id + '": ' + error); + }); + }); +}; + +/** + * Programmatically set the response following the json schema described in + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * @param {Object} interaction + * @param {Object} response + */ +var setResponse = function setResponse(interaction, response) { + instanciator.getPci(interaction).setResponse(response); +}; + +/** + * Get the response in the json format described in + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * @param {Object} interaction + * @returns {Object} + */ +var getResponse = function getResponse(interaction) { + return instanciator.getPci(interaction).getResponse(); +}; + +/** + * Remove the current response set in the interaction + * The state may not be restored at this point. + * + * @param {Object} interaction + */ +var resetResponse = function resetResponse(interaction) { + instanciator.getPci(interaction).resetResponse(); +}; + +/** + * Reverse operation performed by render() + * After this function is executed, only the inital naked markup remains + * Event listeners are removed and the state and the response are reset + * + * @param {Object} interaction + * @returns {Promise?} the interaction destroy step can be async and can return an optional Promise + */ +var destroy = function destroy(interaction) { + return _getPciRenderer(interaction).destroy(interaction); +}; + +/** + * Restore the state of the interaction from the serializedState. + * + * @param {Object} interaction + * @param {Object} serializedState - json format + */ +var setState = function setState(interaction, serializedState) { + _getPciRenderer(interaction).setState(interaction, serializedState); +}; + +/** + * Get the current state of the interaction as a string. + * It enables saving the state for later usage. + * + * @param {Object} interaction + * @returns {Object} json format + */ +var getState = function getState(interaction) { + return _getPciRenderer(interaction).getState(interaction); +}; + +export default { + qtiClass: 'customInteraction', + template: tpl, + getData: function(customInteraction, data) { + //remove ns + fix media file path + var markup = data.markup; + markup = util.removeMarkupNamespaces(markup); + markup = PortableElement.fixMarkupMediaSources(markup, this); + data.markup = markup; + + return data; + }, + render: render, + getContainer: containerHelper.get, + setResponse: setResponse, + getResponse: getResponse, + resetResponse: resetResponse, + destroy: destroy, + getState: getState, + setState: setState +}; diff --git a/src/qtiCommonRenderer/renderers/interactions/Prompt.js b/src/qtiCommonRenderer/renderers/interactions/Prompt.js new file mode 100644 index 00000000..2111a41d --- /dev/null +++ b/src/qtiCommonRenderer/renderers/interactions/Prompt.js @@ -0,0 +1,31 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ + +/** + * @author Sam Sipasseuth + * @author Bertrand Chevrier + */ +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/prompt'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; + +export default { + qtiClass: 'prompt', + template: tpl, + getContainer: containerHelper.get +}; diff --git a/src/qtiCommonRenderer/renderers/interactions/SelectPointInteraction.js b/src/qtiCommonRenderer/renderers/interactions/SelectPointInteraction.js new file mode 100644 index 00000000..8ae040c7 --- /dev/null +++ b/src/qtiCommonRenderer/renderers/interactions/SelectPointInteraction.js @@ -0,0 +1,306 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014-2017 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ + +/** + * The Common Render for the Select Point Interaction + * + * @author Bertrand Chevrier + */ +import $ from 'jquery'; +import _ from 'lodash'; +import Promise from 'core/promise'; +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/selectPointInteraction'; +import graphic from 'taoQtiItem/qtiCommonRenderer/helpers/Graphic'; +import pciResponse from 'taoQtiItem/qtiCommonRenderer/helpers/PciResponse'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; +import instructionMgr from 'taoQtiItem/qtiCommonRenderer/helpers/instructions/instructionManager'; + +/** + * Get the responses from the interaction + * @param {Object} interaction + * @returns {Array} of points + */ +var getRawResponse = function getRawResponse(interaction) { + if (interaction && interaction.paper && _.isArray(interaction.paper.points)) { + return _.map(interaction.paper.points, function(point) { + return [point.x, point.y]; + }); + } + return []; +}; + +/** + * Add a new point to the interaction + * @param {Object} interaction + * @param {Object} point - the x/y point + */ +var addPoint = function addPoint(interaction, point) { + var maxChoices = interaction.attr('maxChoices'); + + var pointChange = function pointChange() { + containerHelper.triggerResponseChangeEvent(interaction); + instructionMgr.validateInstructions(interaction); + }; + + if (maxChoices > 0 && getRawResponse(interaction).length >= maxChoices) { + instructionMgr.validateInstructions(interaction); + } else { + if (!_.isArray(interaction.paper.points)) { + interaction.paper.points = []; + } + + graphic.createTarget(interaction.paper, { + point: point, + create: function create(target) { + if (interaction.isTouch && target && target.getBBox) { + graphic.createTouchCircle(interaction.paper, target.getBBox()); + } + + interaction.paper.points.push(point); + + pointChange(); + }, + remove: function remove() { + _.remove(interaction.paper.points, point); + + pointChange(); + } + }); + } +}; + +/** + * Make the image clickable and place targets at the given position. + * @param {Object} interaction + */ +var enableSelection = function enableSelection(interaction) { + var $container = containerHelper.get(interaction); + var $imageBox = $container.find('.main-image-box'); + var isResponsive = $container.hasClass('responsive'); + var image = interaction.paper.getById('bg-image-' + interaction.serial); + + interaction.paper.isTouch = false; + + //used to see if we are in a touch context + image.touchstart(function() { + interaction.paper.isTouch = true; + image.untouchstart(); + }); + + //get the point on click + image.click(function imageClicked(event) { + addPoint(interaction, graphic.getPoint(event, interaction.paper, $imageBox, isResponsive)); + }); +}; + +/** + * Init rendering, called after template injected into the DOM + * All options are listed in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 + * + * @param {Object} interaction + */ +var render = function render(interaction) { + var self = this; + + return new Promise(function(resolve) { + var $container = containerHelper.get(interaction); + var background = interaction.object.attributes; + + $container.off('resized.qti-widget.resolve').one('resized.qti-widget.resolve', resolve); + + //create the paper + interaction.paper = graphic.responsivePaper('graphic-paper-' + interaction.serial, interaction.serial, { + width: background.width, + height: background.height, + img: self.resolveUrl(background.data), + imgId: 'bg-image-' + interaction.serial, + container: $container + }); + + //enable to select the paper to position a target + enableSelection(interaction); + + //set up the constraints instructions + instructionMgr.minMaxChoiceInstructions(interaction, { + min: interaction.attr('minChoices'), + max: interaction.attr('maxChoices'), + choiceCount: false, + getResponse: getRawResponse, + onError: function(data) { + if (data) { + graphic.highlightError(data.target, 'success'); + } + } + }); + }); +}; + +/** + * Set the response to the rendered interaction. + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 + * + * @param {Object} interaction + * @param {Object} response + */ +var setResponse = function(interaction, response) { + var responseValues; + + if (response && interaction.paper) { + try { + responseValues = pciResponse.unserialize(response, interaction); + + if (interaction.getResponseDeclaration().attr('cardinality') === 'single') { + responseValues = [responseValues]; + } + _(responseValues) + .filter(function(point) { + return _.isArray(point) && point.length === 2; + }) + .forEach(function(point) { + addPoint(interaction, { + x: point[0], + y: point[1] + }); + }); + } catch (err) { + return; + } + } +}; + +/** + * Reset the current responses of the rendered interaction. + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 + * + * Special value: the empty object value {} resets the interaction responses + * + * @param {Object} interaction + */ +var resetResponse = function resetResponse(interaction) { + if (interaction && interaction.paper) { + interaction.paper.points = []; + + interaction.paper.forEach(function(element) { + var point = element.data('point'); + if (typeof point === 'object') { + graphic.trigger(element, 'click'); + } + }); + } +}; + +/** + i* Return the response of the rendered interaction + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 + * + * @param {Object} interaction + * @returns {Object} the response + */ +var getResponse = function(interaction) { + return pciResponse.serialize(getRawResponse(interaction), interaction); +}; + +/** + * Clean interaction destroy + * @param {Object} interaction + */ +var destroy = function destroy(interaction) { + var $container; + if (interaction.paper) { + $container = containerHelper.get(interaction); + + $(window).off('resize.qti-widget.' + interaction.serial); + $container.off('resize.qti-widget.' + interaction.serial); + + interaction.paper.clear(); + instructionMgr.removeInstructions(interaction); + + $('.main-image-box', $container) + .empty() + .removeAttr('style'); + $('.image-editor', $container).removeAttr('style'); + } + + //remove all references to a cache container + containerHelper.reset(interaction); +}; + +/** + * Set the interaction state. It could be done anytime with any state. + * + * @param {Object} interaction - the interaction instance + * @param {Object} state - the interaction state + */ +var setState = function setState(interaction, state) { + if (_.isObject(state)) { + if (state.response) { + interaction.resetResponse(); + interaction.setResponse(state.response); + } + } +}; + +/** + * Get the interaction state. + * + * @param {Object} interaction - the interaction instance + * @returns {Object} the interaction current state + */ +var getState = function getState(interaction) { + var state = {}; + var response = interaction.getResponse(); + + if (response) { + state.response = response; + } + return state; +}; + +/** + * Expose the common renderer for the interaction + * @exports qtiCommonRenderer/renderers/interactions/SelectPointInteraction + */ +export default { + qtiClass: 'selectPointInteraction', + template: tpl, + render: render, + getContainer: containerHelper.get, + setResponse: setResponse, + getResponse: getResponse, + resetResponse: resetResponse, + destroy: destroy, + setState: setState, + getState: getState +}; diff --git a/src/qtiCommonRenderer/renderers/interactions/SliderInteraction.js b/src/qtiCommonRenderer/renderers/interactions/SliderInteraction.js new file mode 100644 index 00000000..64f2d3bb --- /dev/null +++ b/src/qtiCommonRenderer/renderers/interactions/SliderInteraction.js @@ -0,0 +1,285 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ + +/** + * @author Sam Sipasseuth + * @author Bertrand Chevrier + */ +import $ from 'jquery'; +import _ from 'lodash'; +import __ from 'i18n'; +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/sliderInteraction'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; +import pciResponse from 'taoQtiItem/qtiCommonRenderer/helpers/PciResponse'; +import 'nouislider'; + +var _slideTo = function(options) { + options.sliderCurrentValue.find('.qti-slider-cur-value').text(options.value); + options.sliderValue.val(options.value); +}; + +/** + * Init rendering, called after template injected into the DOM + * All options are listed in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10333 + * + * @param {object} interaction + */ +var render = function(interaction) { + var attributes = interaction.getAttributes(), + $container = interaction.getContainer(), + $el = $('
    ').attr({ id: attributes.responseIdentifier + '-qti-slider', class: 'qti-slider' }), //slider element + $sliderLabels = $('
    ').attr({ class: 'qti-slider-values' }), + $sliderCurrentValue = $('
    ').attr({ + id: attributes.responseIdentifier + '-qti-slider-cur-value', + class: 'qti-slider-cur-value' + }), //show the current selected value + $sliderValue = $('').attr({ type: 'hidden', id: attributes.responseIdentifier + '-qti-slider-value' }); //the input that always holds the slider value + + //getting the options + var orientation = 'horizontal', + reverse = typeof attributes.reverse !== 'undefined' && attributes.reverse ? true : false, //setting the slider whether to be reverse or not + min = parseInt(attributes.lowerBound), + max = parseInt(attributes.upperBound), + step = typeof attributes.step !== 'undefined' && attributes.step ? parseInt(attributes.step) : 1, //default value as per QTI standard + steps = (max - min) / step; //number of the steps + + //add the containers + $sliderCurrentValue + .append('' + __('Current value:') + ' ') + .append(''); + + $sliderLabels + .append('' + (!reverse ? min : max) + '') + .append('' + (!reverse ? max : min) + ''); + + interaction + .getContainer() + .append($el) + .append($sliderLabels) + .append($sliderCurrentValue) + .append($sliderValue); + + //setting the orientation of the slider + if ( + typeof attributes.orientation !== 'undefined' && + $.inArray(attributes.orientation, ['horizontal', 'vertical']) > -1 + ) { + orientation = attributes.orientation; + } + + var sliderSize = 0; + + if (orientation === 'horizontal') { + $container.addClass('qti-slider-horizontal'); + } else { + var maxHeight = 300; + sliderSize = steps * 20; + if (sliderSize > maxHeight) { + sliderSize = maxHeight; + } + $container.addClass('qti-slider-vertical'); + $el.height(sliderSize + 'px'); + $sliderLabels.height(sliderSize + 'px'); + } + + //set the middle value if the stepLabel attribute is enabled + if (typeof attributes.stepLabel !== 'undefined' && attributes.stepLabel) { + var middleStep = parseInt(steps / 2), + leftOffset = (100 / steps) * middleStep, + middleValue = reverse ? max - middleStep * step : min + middleStep * step; + + if (orientation === 'horizontal') { + $sliderLabels + .find('.slider-min') + .after('' + middleValue + ''); + } else { + $sliderLabels + .find('.slider-min') + .after('' + middleValue + ''); + } + } + + //create the slider + $el.noUiSlider({ + start: reverse ? max : min, + range: { + min: min, + max: max + }, + step: step, + orientation: orientation + }).on('slide', function(e) { + var val = parseInt($(this).val()); + if (interaction.attr('reverse')) { + val = max + min - val; + } + val = Math.round(val * 1000) / 1000; + _slideTo({ + value: val, + sliderValue: $sliderValue, + sliderCurrentValue: $sliderCurrentValue + }); + + containerHelper.triggerResponseChangeEvent(interaction); + }); + + _slideTo({ + value: min, + sliderValue: $sliderValue, + sliderCurrentValue: $sliderCurrentValue + }); +}; + +var resetResponse = function(interaction) { + var attributes = interaction.getAttributes(), + $el = $('#' + attributes.responseIdentifier + '-qti-slider'), + $sliderValue = $('#' + attributes.responseIdentifier + '-qti-slider-value'), + $sliderCurrentValue = $('#' + attributes.responseIdentifier + '-qti-slider-cur-value'), + min = parseInt(attributes.lowerBound), + max = parseInt(attributes.upperBound), + reverse = typeof attributes.reverse !== 'undefined' && attributes.reverse ? true : false, + startValue = reverse ? max : min; + + _slideTo({ + value: min, + sliderValue: $sliderValue, + sliderCurrentValue: $sliderCurrentValue + }); + + $el.val(startValue); +}; + +/** + * Set the response to the rendered interaction. + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10333 + * + * @param {object} interaction + * @param {object} response + */ +var setResponse = function(interaction, response) { + var attributes = interaction.getAttributes(), + $sliderValue = $('#' + attributes.responseIdentifier + '-qti-slider-value'), + $sliderCurrentValue = $('#' + attributes.responseIdentifier + '-qti-slider-cur-value'), + $el = $('#' + attributes.responseIdentifier + '-qti-slider'), + min = parseInt(attributes.lowerBound), + max = parseInt(attributes.upperBound), + value; + + value = pciResponse.unserialize(response, interaction)[0]; + + _slideTo({ + value: value, + sliderValue: $sliderValue, + sliderCurrentValue: $sliderCurrentValue + }); + + $el.val(interaction.attr('reverse') ? max + min - value : value); +}; + +var _getRawResponse = function(interaction) { + var value, + attributes = interaction.getAttributes(), + baseType = interaction.getResponseDeclaration().attr('baseType'), + min = parseInt(attributes.lowerBound), + $sliderValue = $('#' + attributes.responseIdentifier + '-qti-slider-value'); + + if (baseType === 'integer') { + value = parseInt($sliderValue.val()); + } else if (baseType === 'float') { + value = parseFloat($sliderValue.val()); + } + + return isNaN(value) ? min : value; +}; + +/** + * Return the response of the rendered interaction + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10333 + * + * @param {object} interaction + * @returns {object} + */ +var getResponse = function(interaction) { + return pciResponse.serialize([_getRawResponse(interaction)], interaction); +}; + +var destroy = function(interaction) { + var $container = interaction.getContainer(); + + $container.empty(); + + //remove all references to a cache container + containerHelper.reset(interaction); +}; + +/** + * Set the interaction state. It could be done anytime with any state. + * + * @param {Object} interaction - the interaction instance + * @param {Object} state - the interaction state + */ +var setState = function setState(interaction, state) { + if (_.isObject(state)) { + if (state.response) { + interaction.resetResponse(); + interaction.setResponse(state.response); + } + } +}; + +/** + * Get the interaction state. + * + * @param {Object} interaction - the interaction instance + * @returns {Object} the interaction current state + */ +var getState = function getState(interaction) { + var $container; + var state = {}; + var response = interaction.getResponse(); + + if (response) { + state.response = response; + } + return state; +}; + +export default { + qtiClass: 'sliderInteraction', + template: tpl, + render: render, + getContainer: containerHelper.get, + setResponse: setResponse, + getResponse: getResponse, + resetResponse: resetResponse, + destroy: destroy, + setState: setState, + getState: getState +}; diff --git a/src/qtiCommonRenderer/renderers/interactions/TextEntryInteraction.js b/src/qtiCommonRenderer/renderers/interactions/TextEntryInteraction.js new file mode 100644 index 00000000..171986ab --- /dev/null +++ b/src/qtiCommonRenderer/renderers/interactions/TextEntryInteraction.js @@ -0,0 +1,291 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + * + */ + +/** + * @author Sam Sipasseuth + * @author Bertrand Chevrier + */ +import $ from 'jquery'; +import _ from 'lodash'; +import __ from 'i18n'; +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/textEntryInteraction'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; +import instructionMgr from 'taoQtiItem/qtiCommonRenderer/helpers/instructions/instructionManager'; +import pciResponse from 'taoQtiItem/qtiCommonRenderer/helpers/PciResponse'; +import patternMaskHelper from 'taoQtiItem/qtiCommonRenderer/helpers/patternMask'; +import locale from 'util/locale'; +import tooltip from 'ui/tooltip'; + +/** + * Hide the tooltip for the text input + * @param {jQuery} $input + */ +var hideTooltip = function hideTooltip($input) { + if ($input.data('$tooltip')) { + $input.data('$tooltip').hide(); + } +}; + +/** + * Create/Show tooltip for the text input + * @param {jQuery} $input + * @param {String} theme + * @param {String} message + */ +var showTooltip = function showTooltip($input, theme, message) { + if ($input.data('$tooltip')) { + $input.data('$tooltip').updateTitleContent(message); + } else { + var textEntryTooltip = tooltip.create($input, message, { + theme: theme, + trigger: 'manual' + }); + + $input.data('$tooltip', textEntryTooltip); + } + + $input.data('$tooltip').show(); +}; + +/** + * Init rendering, called after template injected into the DOM + * All options are listed in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10333 + * + * @param {object} interaction + */ +var render = function render(interaction) { + var attributes = interaction.getAttributes(), + $input = interaction.getContainer(), + expectedLength, + updateMaxCharsTooltip, + updatePatternMaskTooltip, + patternMask = interaction.attr('patternMask'), + maxChars = parseInt(patternMaskHelper.parsePattern(patternMask, 'chars'), 10); + + //setting up the width of the input field + if (attributes.expectedLength) { + //adding 2 chars to include reasonable padding size + expectedLength = parseInt(attributes.expectedLength) + 2; + $input.css('width', expectedLength + 'ch'); + $input.css('min-width', expectedLength + 'ch'); + } + + //checking if there's a placeholder for the input + if (attributes.placeholderText) { + $input.attr('placeholder', attributes.placeholderText); + } + + if (maxChars) { + updateMaxCharsTooltip = function updateMaxCharsTooltip() { + var count = $input.val().length; + var message, messageType; + + if (count) { + message = __('%d/%d', count, maxChars); + } else { + message = __('%d characters allowed', maxChars); + } + + if (count >= maxChars) { + $input.addClass('maxed'); + messageType = 'warning'; + } else { + $input.removeClass('maxed'); + messageType = 'info'; + } + + showTooltip($input, messageType, message); + }; + + $input + .attr('maxlength', maxChars) + .on('focus.commonRenderer', function() { + updateMaxCharsTooltip(); + }) + .on('keyup.commonRenderer', function() { + updateMaxCharsTooltip(); + containerHelper.triggerResponseChangeEvent(interaction); + }) + .on('blur.commonRenderer', function() { + hideTooltip($input); + }); + } else if (attributes.patternMask) { + updatePatternMaskTooltip = function updatePatternMaskTooltip() { + var regex = new RegExp(attributes.patternMask); + + hideTooltip($input); + + if ($input.val().length && regex.test($input.val())) { + $input.removeClass('invalid'); + } else { + $input.addClass('invalid'); + showTooltip($input, 'error', __('This is not a valid answer')); + } + }; + + $input + .on('focus.commonRenderer', function() { + updatePatternMaskTooltip(); + }) + .on('keyup.commonRenderer', function() { + updatePatternMaskTooltip(); + containerHelper.triggerResponseChangeEvent(interaction); + }) + .on('blur.commonRenderer', function() { + hideTooltip($input); + }); + } else { + $input.on('keyup.commonRenderer', function() { + containerHelper.triggerResponseChangeEvent(interaction); + }); + } +}; + +var resetResponse = function resetResponse(interaction) { + interaction.getContainer().val(''); +}; + +/** + * Set the response to the rendered interaction. + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10333 + * + * Special value: the empty object value {} resets the interaction responses + * + * @param {object} interaction + * @param {object} response + */ +var setResponse = function setResponse(interaction, response) { + var responseValue; + + try { + responseValue = pciResponse.unserialize(response, interaction); + } catch (e) {} + + if (responseValue && responseValue.length) { + interaction.getContainer().val(responseValue[0]); + } +}; + +/** + * Return the response of the rendered interaction + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10333 + * + * @param {object} interaction + * @returns {object} + */ +var getResponse = function getResponse(interaction) { + var ret = { base: {} }, + value, + $input = interaction.getContainer(), + attributes = interaction.getAttributes(), + baseType = interaction.getResponseDeclaration().attr('baseType'), + numericBase = attributes.base || 10; + + if ($input.hasClass('invalid') || (attributes.placeholderText && $input.val() === attributes.placeholderText)) { + //invalid response or response equals to the placeholder text are considered empty + value = ''; + } else { + if (baseType === 'integer') { + value = locale.parseInt($input.val(), numericBase); + } else if (baseType === 'float') { + value = locale.parseFloat($input.val()); + } else if (baseType === 'string') { + value = $input.val(); + } + } + + ret.base[baseType] = isNaN(value) && typeof value === 'number' ? '' : value; + + return ret; +}; + +var destroy = function destroy(interaction) { + $('input.qti-textEntryInteraction').each(function(index, el) { + var $input = $(el); + if ($input.data('$tooltip')) { + $input.data('$tooltip').dispose(); + $input.removeData('$tooltip'); + } + }); + + //remove event + $(document).off('.commonRenderer'); + containerHelper.get(interaction).off('.commonRenderer'); + + //remove instructions + instructionMgr.removeInstructions(interaction); + + //remove all references to a cache container + containerHelper.reset(interaction); +}; + +/** + * Set the interaction state. It could be done anytime with any state. + * + * @param {Object} interaction - the interaction instance + * @param {Object} state - the interaction state + */ +var setState = function setState(interaction, state) { + if (_.isObject(state)) { + if (state.response) { + interaction.resetResponse(); + interaction.setResponse(state.response); + } + } +}; + +/** + * Get the interaction state. + * + * @param {Object} interaction - the interaction instance + * @returns {Object} the interaction current state + */ +var getState = function getState(interaction) { + var state = {}; + var response = interaction.getResponse(); + + if (response) { + state.response = response; + } + return state; +}; + +export default { + qtiClass: 'textEntryInteraction', + template: tpl, + render: render, + getContainer: containerHelper.get, + setResponse: setResponse, + getResponse: getResponse, + resetResponse: resetResponse, + destroy: destroy, + setState: setState, + getState: getState +}; diff --git a/src/qtiCommonRenderer/renderers/interactions/UploadInteraction.js b/src/qtiCommonRenderer/renderers/interactions/UploadInteraction.js new file mode 100644 index 00000000..bc1a73f6 --- /dev/null +++ b/src/qtiCommonRenderer/renderers/interactions/UploadInteraction.js @@ -0,0 +1,388 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ + +/** + * @author Sam Sipasseuth + * @author Bertrand Chevrier + */ +import $ from 'jquery'; +import _ from 'lodash'; +import __ from 'i18n'; +import mimetype from 'core/mimetype'; +import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/uploadInteraction'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; +import instructionMgr from 'taoQtiItem/qtiCommonRenderer/helpers/instructions/instructionManager'; +import uploadHelper from 'taoQtiItem/qtiCommonRenderer/helpers/uploadMime'; +import 'ui/progressbar'; +import 'ui/previewer'; +import 'ui/modal'; +import 'ui/waitForMedia'; + +var _initialInstructions = __('Browse your computer and select the appropriate file.'); + +var _readyInstructions = __('The selected file is ready to be sent.'); + +/** + * Validate type of selected file + * @param file + * @param interaction + * @returns {boolean} + */ +var validateFileType = function validateFileType(file, interaction) { + var expectedTypes = uploadHelper.getExpectedTypes(interaction, true); + var filetype = mimetype.getMimeType(file); + if (expectedTypes.length) { + return _.indexOf(expectedTypes, filetype) >= 0; + } + return true; +}; + +/** + * Compute the message to be displayed when an invalid file type has been selected + * + * @param {Object} interaction + * @param {Function} userSelectedType + * @param {Function} messageWrongType + * @returns {String} + */ +var getMessageWrongType = function getMessageWrongType(interaction, userSelectedType, messageWrongType) { + var types = uploadHelper.getExpectedTypes(interaction); + var expectedTypeLabels = _.map(_.uniq(types), function(type) { + var mime = _.find(uploadHelper.getMimeTypes(), { mime: type }); + if (mime) { + return mime.label; + } else { + return type; + } + }); + + if (messageWrongType && _.isFunction(messageWrongType)) { + return messageWrongType({ + userSelectedType: userSelectedType, + types: expectedTypeLabels + }); + } else { + return __( + 'Wrong type of file. Expected %s. The selected file has the mimetype "%s".', + expectedTypeLabels.join(__(' or ')), + userSelectedType + ); + } +}; + +var _handleSelectedFiles = function _handleSelectedFiles(interaction, file, messageWrongType) { + var reader; + var $container = containerHelper.get(interaction); + + // Show information about the processed file to the candidate. + var filename = file.name; + var filetype = mimetype.getMimeType(file); + instructionMgr.removeInstructions(interaction); + instructionMgr.appendInstruction(interaction, _initialInstructions); + + if (!validateFileType(file, interaction)) { + instructionMgr.removeInstructions(interaction); + instructionMgr.appendInstruction( + interaction, + getMessageWrongType(interaction, filetype, messageWrongType), + function() { + this.setLevel('error'); + //clear preview + } + ); + instructionMgr.validateInstructions(interaction); + return; + } + + $container + .find('.file-name') + .empty() + .append(filename); + + // Let's read the file to get its base64 encoded content. + reader = new FileReader(); + + // Update file processing progress. + + reader.onload = function(e) { + var base64Data, commaPosition, base64Raw, $previewArea; + + instructionMgr.removeInstructions(interaction); + instructionMgr.appendInstruction(interaction, _readyInstructions, function() { + this.setLevel('success'); + }); + instructionMgr.validateInstructions(interaction); + + $container.find('.progressbar').progressbar('value', 100); + + base64Data = e.target.result; + commaPosition = base64Data.indexOf(','); + + // Store the base64 encoded data for later use. + base64Raw = base64Data.substring(commaPosition + 1); + interaction.data('_response', { base: { file: { data: base64Raw, mime: filetype, name: filename } } }); + + $previewArea = $container.find('.file-upload-preview'); + $previewArea.previewer({ + url: base64Data, + name: filename, + mime: filetype + }); + + // we wait for the image to be completely loaded + $previewArea.waitForMedia(function() { + var $originalImg = $previewArea.find('img'), + $largeDisplay = $('.file-upload-preview-popup'), + $item = $('.qti-item'), + itemWidth = $item.width(), + winWidth = $(window).width() - 80, + fullHeight = $('body').height(), + imgNaturalWidth, + isOversized, + modalWidth; + + if (!$originalImg.length) { + return; + } + + imgNaturalWidth = $originalImg[0].naturalWidth; + isOversized = imgNaturalWidth > itemWidth; + modalWidth = Math.min(winWidth, imgNaturalWidth); + + $previewArea.toggleClass('clickable', isOversized); + + if (!isOversized) { + return; + } + + $previewArea.on('click', function() { + var $modalBody; + + $('.upload-ia-modal-bg').remove(); + + // remove any previous unnecessary content before inserting the preview image + $modalBody = $largeDisplay.find('.modal-body'); + $modalBody.empty().append($originalImg.clone()); + + $largeDisplay + .on('opened.modal', function() { + // prevents the rest of the page from scrolling when modal is open + $('.tao-item-scope.tao-preview-scope').css('overflow', 'hidden'); + + $largeDisplay.css({ + width: modalWidth, + height: fullHeight, + left: (modalWidth - itemWidth - 40) / -2 + }); + }) + .on('closed.modal', function() { + // make the page scrollable again + $('.tao-item-scope.tao-preview-scope').css('overflow', 'auto'); + }) + .modal({ modalOverlayClass: 'modal-bg upload-ia-modal-bg' }); + }); + }); + }; + + reader.onloadstart = function onloadstart() { + instructionMgr.removeInstructions(interaction); + $container.find('.progressbar').progressbar('value', 0); + }; + + reader.onprogress = function onprogress(e) { + var percentProgress = Math.ceil((Math.round(e.loaded) / Math.round(e.total)) * 100); + $container.find('.progressbar').progressbar('value', percentProgress); + }; + + reader.readAsDataURL(file); +}; + +var _resetGui = function _resetGui(interaction) { + var $container = containerHelper.get(interaction); + $container.find('.file-name').text(__('No file selected')); + $container.find('.btn-info').text(__('Browse...')); +}; + +/** + * Init rendering, called after template injected into the DOM + * All options are listed in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 + * + * @param {object} interaction + */ +var render = function render(interaction) { + var changeListener, + self = this, + $input; + var $container = containerHelper.get(interaction); + _resetGui(interaction); + + instructionMgr.appendInstruction(interaction, _initialInstructions); + + //init response + interaction.data('_response', { base: null }); + + changeListener = function(e) { + var file = e.target.files[0]; + + // Are you really sure something was selected + // by the user... huh? :) + if (typeof file !== 'undefined') { + _handleSelectedFiles(interaction, file, self.getCustomMessage('upload', 'wrongType')); + } + }; + + $input = $container.find('input'); + + $container.find('.progressbar').progressbar(); + + if (!window.FileReader) { + throw new Error('FileReader API not supported! Please use a compliant browser!'); + } + $input.bind('change', changeListener); + + // IE Specific hack, prevents button to slightly move on click + $input.bind('mousedown', function(e) { + e.preventDefault(); + $(this).blur(); + return false; + }); +}; + +var resetResponse = function resetResponse(interaction) { + _resetGui(interaction); +}; + +/** + * Set the response to the rendered interaction. + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 + * + * @param {object} interaction + * @param {object} response + */ +var setResponse = function setResponse(interaction, response) { + var filename, + $container = containerHelper.get(interaction); + + if (response.base !== null) { + filename = + typeof response.base.file.name !== 'undefined' ? response.base.file.name : 'previously-uploaded-file'; + $container + .find('.file-name') + .empty() + .text(filename); + } + + interaction.data('_response', response); +}; + +/** + * Return the response of the rendered interaction + * + * The response format follows the IMS PCI recommendation : + * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 + * + * Available base types are defined in the QTI v2.1 information model: + * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 + * + * @param {object} interaction + * @returns {object} + */ +var getResponse = function getResponse(interaction) { + return interaction.data('_response'); +}; + +var destroy = function destroy(interaction) { + //remove event + $(document).off('.commonRenderer'); + containerHelper.get(interaction).off('.commonRenderer'); + + //remove instructions + instructionMgr.removeInstructions(interaction); + + //remove all references to a cache container + containerHelper.reset(interaction); +}; + +/** + * Set the interaction state. It could be done anytime with any state. + * + * @param {Object} interaction - the interaction instance + * @param {Object} state - the interaction state + */ +var setState = function setState(interaction, state) { + if (_.isObject(state)) { + if (state.response) { + interaction.resetResponse(); + interaction.setResponse(state.response); + } + } +}; + +/** + * Get the interaction state. + * + * @param {Object} interaction - the interaction instance + * @returns {Object} the interaction current state + */ +var getState = function getState(interaction) { + var state = {}; + var response = interaction.getResponse(); + + if (response) { + state.response = response; + } + return state; +}; + +/** + * Set additional data to the template (data that are not really part of the model). + * @param {Object} interaction - the interaction + * @param {Object} [data] - interaction custom data + * @returns {Object} custom data + * This way we could cover a lot more types. How could this be matched with the preview templates + * in tao/views/js/ui/previewer.js + */ +var getCustomData = function getCustomData(interaction, data) { + return _.merge(data || {}, { + accept: uploadHelper.getExpectedTypes(interaction, true).join(',') + }); +}; + +export default { + qtiClass: 'uploadInteraction', + template: tpl, + render: render, + getContainer: containerHelper.get, + setResponse: setResponse, + getResponse: getResponse, + resetResponse: resetResponse, + destroy: destroy, + setState: setState, + getState: getState, + getData: getCustomData, + + // Exposed private methods for qtiCreator + resetGui: _resetGui +}; diff --git a/src/qtiCommonRenderer/renderers/interactions/pci/common.js b/src/qtiCommonRenderer/renderers/interactions/pci/common.js new file mode 100644 index 00000000..68fab769 --- /dev/null +++ b/src/qtiCommonRenderer/renderers/interactions/pci/common.js @@ -0,0 +1,81 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2017 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ +import _ from 'lodash'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; +import instanciator from 'taoQtiItem/qtiCommonRenderer/renderers/interactions/pci/instanciator'; + +export default function commonPciRenderer(runtime) { + return { + getRequiredModules: function getRequiredModules() { + var requireEntries = []; + //load hook if applicable + if (runtime.hook) { + requireEntries.push(runtime.hook.replace(/\.js$/, '')); + } + //load libs + _.forEach(runtime.libraries, function(lib) { + requireEntries.push(lib.replace(/\.js$/, '')); + }); + //load stylesheets + _.forEach(runtime.stylesheets, function(stylesheet) { + requireEntries.push('css!' + stylesheet.replace(/\.css$/, '')); + }); + return requireEntries; + }, + createInstance: function(interaction, context) { + var id = interaction.attr('responseIdentifier'); + var pci = instanciator.getPci(interaction); + var properties = _.clone(interaction.properties); + var assetManager = context.assetManager; + var pciAssetManager = { + resolve: function pciAssetResolve(url) { + var resolved = assetManager.resolveBy('portableElementLocation', url); + if (resolved === url || _.isUndefined(resolved)) { + return assetManager.resolve(url); + } else { + return resolved; + } + } + }; + pci.initialize( + id, + containerHelper + .get(interaction) + .children() + .get(0), + properties, + pciAssetManager + ); + }, + /** + * + * @param {Object }interaction + * @returns {Promise?} the interaction destroy step can be async and can return an optional Promise + */ + destroy: function destroy(interaction) { + return instanciator.getPci(interaction).destroy(); + }, + setState: function setState(interaction, state) { + instanciator.getPci(interaction).setSerializedState(state); + }, + getState: function getState(interaction) { + return instanciator.getPci(interaction).getSerializedState(); + } + }; +} diff --git a/src/qtiCommonRenderer/renderers/interactions/pci/ims.js b/src/qtiCommonRenderer/renderers/interactions/pci/ims.js new file mode 100644 index 00000000..e828b34d --- /dev/null +++ b/src/qtiCommonRenderer/renderers/interactions/pci/ims.js @@ -0,0 +1,75 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2017 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ +import _ from 'lodash'; +import loggerFactory from 'core/logger'; +import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; +import instanciator from 'taoQtiItem/qtiCommonRenderer/renderers/interactions/pci/instanciator'; + +var logger = loggerFactory('taoQtiItem/qtiCommonRenderer/renderers/interactions/pci/ims'); + +var pciReadyCallback = function pciReadyCallback(pci, state) { + //standard callback function to be implemented in a future story + logger.info('pciReadyCallback called on PCI ' + pci.typeIdentifier); +}; + +var pciDoneCallback = function pciDoneCallback(pci, response, state, status) { + //standard callback function to be implemented in a future story + logger.info('pciDoneCallback called on PCI ' + pci.typeIdentifier); +}; + +export default function defaultPciRenderer(runtime) { + return { + getRequiredModules: function getRequiredModules() { + var requireEntries = []; + //load modules + _.forEach(runtime.modules, function(module, name) { + requireEntries.push(name); + }); + return requireEntries; + }, + createInstance: function createInstance(interaction, context) { + var pci = instanciator.getPci(interaction); + var config; + var properties = _.clone(interaction.properties); + + // serialize any array or object properties + _.forOwn(properties, function(propVal, propKey) { + properties[propKey] = _.isArray(propVal) || _.isObject(propVal) ? JSON.stringify(propVal) : propVal; + }); + + config = { + properties: properties, + templateVariables: {}, //not supported yet + boundTo: context.response || {}, + onready: pciReadyCallback, + ondone: pciDoneCallback, + status: 'interacting' //only support interacting state currently(TODO: solution, review), + }; + + pci.getInstance(containerHelper.get(interaction).get(0), config, context.state); + }, + destroy: function destroy(interaction) { + instanciator.getPci(interaction).oncompleted(); + }, + setState: _.noop, + getState: function getState(interaction) { + return instanciator.getPci(interaction).getState(); + } + }; +} diff --git a/src/qtiCommonRenderer/renderers/interactions/pci/instanciator.js b/src/qtiCommonRenderer/renderers/interactions/pci/instanciator.js new file mode 100644 index 00000000..15965641 --- /dev/null +++ b/src/qtiCommonRenderer/renderers/interactions/pci/instanciator.js @@ -0,0 +1,47 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2017 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ +import qtiCustomInteractionContext from 'qtiCustomInteractionContext'; + +export default { + /** + * Get the PCI instance associated to the interaction object + * If none exists, create a new one based on the PCI typeIdentifier + * + * @param {Object} interaction - the js object representing the interaction + * @returns {Object} PCI instance + */ + getPci: function getPci(interaction) { + var pciTypeIdentifier, + pci = interaction.data('pci'); + + if (!pci) { + pciTypeIdentifier = interaction.typeIdentifier; + pci = qtiCustomInteractionContext.createPciInstance(pciTypeIdentifier); + if (pci) { + //binds the PCI instance to TAO interaction object and vice versa + interaction.data('pci', pci); + pci._taoCustomInteraction = interaction; + } else { + throw new Error('no custom interaction hook found for the type ' + pciTypeIdentifier); + } + } + + return pci; + } +}; diff --git a/src/qtiCommonRenderer/tpl/choices/choice.tpl b/src/qtiCommonRenderer/tpl/choices/choice.tpl new file mode 100755 index 00000000..59d82da5 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/choices/choice.tpl @@ -0,0 +1,3 @@ +
  • + {{{body}}} +
  • \ No newline at end of file diff --git a/src/qtiCommonRenderer/tpl/choices/gap.tpl b/src/qtiCommonRenderer/tpl/choices/gap.tpl new file mode 100755 index 00000000..ad174987 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/choices/gap.tpl @@ -0,0 +1,3 @@ + +   + \ No newline at end of file diff --git a/src/qtiCommonRenderer/tpl/choices/gapImg.tpl b/src/qtiCommonRenderer/tpl/choices/gapImg.tpl new file mode 100755 index 00000000..38643f60 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/choices/gapImg.tpl @@ -0,0 +1,7 @@ +
  • + +
  • \ No newline at end of file diff --git a/src/qtiCommonRenderer/tpl/choices/hottext.tpl b/src/qtiCommonRenderer/tpl/choices/hottext.tpl new file mode 100755 index 00000000..58aeb30b --- /dev/null +++ b/src/qtiCommonRenderer/tpl/choices/hottext.tpl @@ -0,0 +1,7 @@ + + + {{{body}}} + \ No newline at end of file diff --git a/src/qtiCommonRenderer/tpl/choices/inlineChoice.tpl b/src/qtiCommonRenderer/tpl/choices/inlineChoice.tpl new file mode 100755 index 00000000..06660a93 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/choices/inlineChoice.tpl @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/src/qtiCommonRenderer/tpl/choices/simpleAssociableChoice.matchInteraction.tpl b/src/qtiCommonRenderer/tpl/choices/simpleAssociableChoice.matchInteraction.tpl new file mode 100755 index 00000000..b1c5e3db --- /dev/null +++ b/src/qtiCommonRenderer/tpl/choices/simpleAssociableChoice.matchInteraction.tpl @@ -0,0 +1,3 @@ + + {{{body}}} + \ No newline at end of file diff --git a/src/qtiCommonRenderer/tpl/choices/simpleChoice.choiceInteraction.tpl b/src/qtiCommonRenderer/tpl/choices/simpleChoice.choiceInteraction.tpl new file mode 100755 index 00000000..cf09c644 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/choices/simpleChoice.choiceInteraction.tpl @@ -0,0 +1,25 @@ +
  • +
    + +
    +
    + {{{body}}} + + + + +
    +
    +
    + +
  • diff --git a/src/qtiCommonRenderer/tpl/container.tpl b/src/qtiCommonRenderer/tpl/container.tpl new file mode 100755 index 00000000..8e2d8fe2 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/container.tpl @@ -0,0 +1,5 @@ +{{~#equal contentModel "blockStatic"~}} +
    {{{body}}}
    +{{~else~}} +{{{body}}} +{{~/equal~}} \ No newline at end of file diff --git a/src/qtiCommonRenderer/tpl/img.tpl b/src/qtiCommonRenderer/tpl/img.tpl new file mode 100644 index 00000000..5c01632b --- /dev/null +++ b/src/qtiCommonRenderer/tpl/img.tpl @@ -0,0 +1,12 @@ +{{attributes.alt}} \ No newline at end of file diff --git a/src/qtiCommonRenderer/tpl/include.tpl b/src/qtiCommonRenderer/tpl/include.tpl new file mode 100755 index 00000000..3afb750b --- /dev/null +++ b/src/qtiCommonRenderer/tpl/include.tpl @@ -0,0 +1,3 @@ +
    + {{{body}}} +
    \ No newline at end of file diff --git a/src/qtiCommonRenderer/tpl/infoControl.tpl b/src/qtiCommonRenderer/tpl/infoControl.tpl new file mode 100644 index 00000000..fdc487d7 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/infoControl.tpl @@ -0,0 +1,3 @@ +
    + {{{markup}}} +
    \ No newline at end of file diff --git a/src/qtiCommonRenderer/tpl/instruction.tpl b/src/qtiCommonRenderer/tpl/instruction.tpl new file mode 100755 index 00000000..3f9eb260 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/instruction.tpl @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/src/qtiCommonRenderer/tpl/interactions/associateInteraction.pair.tpl b/src/qtiCommonRenderer/tpl/interactions/associateInteraction.pair.tpl new file mode 100755 index 00000000..51ef16e8 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/interactions/associateInteraction.pair.tpl @@ -0,0 +1,4 @@ +
  • +
    +
    +
  • diff --git a/src/qtiCommonRenderer/tpl/interactions/associateInteraction.tpl b/src/qtiCommonRenderer/tpl/interactions/associateInteraction.tpl new file mode 100755 index 00000000..5559a7c8 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/interactions/associateInteraction.tpl @@ -0,0 +1,16 @@ +
    + {{#if prompt}}{{{prompt}}}{{/if}} +
    +
      + {{#choices}}{{{.}}}{{/choices}} +
    +
      +
    +
    +
    diff --git a/src/qtiCommonRenderer/tpl/interactions/choiceInteraction.tpl b/src/qtiCommonRenderer/tpl/interactions/choiceInteraction.tpl new file mode 100755 index 00000000..ea3e8f64 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/interactions/choiceInteraction.tpl @@ -0,0 +1,14 @@ +
    + {{#if prompt}}{{{prompt}}}{{/if}} +
    +
      + {{#choices}}{{{.}}}{{/choices}} +
    +
    +
    diff --git a/src/qtiCommonRenderer/tpl/interactions/customInteraction.tpl b/src/qtiCommonRenderer/tpl/interactions/customInteraction.tpl new file mode 100644 index 00000000..b97bcae4 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/interactions/customInteraction.tpl @@ -0,0 +1,3 @@ +
    + {{{markup}}} +
    \ No newline at end of file diff --git a/src/qtiCommonRenderer/tpl/interactions/endAttemptInteraction.tpl b/src/qtiCommonRenderer/tpl/interactions/endAttemptInteraction.tpl new file mode 100755 index 00000000..e0b1be53 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/interactions/endAttemptInteraction.tpl @@ -0,0 +1,8 @@ +
    + {{attributes.title}} + diff --git a/src/qtiCommonRenderer/tpl/interactions/extendedTextInteraction.tpl b/src/qtiCommonRenderer/tpl/interactions/extendedTextInteraction.tpl new file mode 100755 index 00000000..bf0f4658 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/interactions/extendedTextInteraction.tpl @@ -0,0 +1,55 @@ +
    + {{#if prompt}}{{{prompt}}}{{/if}} +
    + {{#if multiple}} + {{#equal attributes.format "xhtml"}} + {{#each maxStringLoop}} +
    + {{/each}} + {{else}} + {{#each maxStringLoop}} + + {{/each}} + {{/equal}} + {!-- If there's an expected length or a max length --}} + {{#if attributes.expectedLength}} +
    + 0 {{__ "of"}} {{attributes.expectedLength}} {{__ "chars"}} {{__ "recommanded"}}. +
    + {{/if}} + {{#if maxLength}} +
    + 0 {{__ "of"}} {{maxLength}} {{__ "chars"}} {{__ "maximum"}}. +
    + {{/if}} + {{!-- If there's a max words --}} + {{#if maxWords}} +
    + 0 {{__ "of"}} {{maxWords}} {{__ "words"}} {{__ "maximum"}}. +
    + {{/if}} + {{else}} + {{#equal attributes.format xhtml}} +
    + {{else}} + + {{/equal}} + {{!-- If there's an expected length or a max length --}} + {{#if attributes.expectedLength}} +
    + 0 {{__ "of"}} {{attributes.expectedLength}} {{__ "chars"}} {{__ "recommended"}}. +
    + {{/if}} + {{#if maxLength}} +
    + 0 {{__ "of"}} {{maxLength}} {{__ "chars"}} {{__ "maximum"}}. +
    + {{/if}} + {{!-- If there's a max words --}} + {{#if maxWords}} +
    + 0 {{__ "of"}} {{maxWords}} {{__ "words"}} {{__ "maximum"}}. +
    + {{/if}} + {{/if}} +
    diff --git a/src/qtiCommonRenderer/tpl/interactions/gapMatchInteraction.tpl b/src/qtiCommonRenderer/tpl/interactions/gapMatchInteraction.tpl new file mode 100644 index 00000000..5a300525 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/interactions/gapMatchInteraction.tpl @@ -0,0 +1,8 @@ +
    + {{#if prompt}}{{{prompt}}}{{/if}} +
      + {{#choices}}{{{.}}}{{/choices}} +
    +
    +
    {{{body}}}
    +
    diff --git a/src/qtiCommonRenderer/tpl/interactions/graphicAssociateInteraction.tpl b/src/qtiCommonRenderer/tpl/interactions/graphicAssociateInteraction.tpl new file mode 100755 index 00000000..399a4ca9 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/interactions/graphicAssociateInteraction.tpl @@ -0,0 +1,7 @@ +
    + {{#if prompt}}{{{prompt}}}{{/if}} +
    +
    +
    +
    +
    diff --git a/src/qtiCommonRenderer/tpl/interactions/graphicGapMatchInteraction.tpl b/src/qtiCommonRenderer/tpl/interactions/graphicGapMatchInteraction.tpl new file mode 100755 index 00000000..476550cf --- /dev/null +++ b/src/qtiCommonRenderer/tpl/interactions/graphicGapMatchInteraction.tpl @@ -0,0 +1,11 @@ +
    + {{#if prompt}}{{{prompt}}}{{/if}} +
    +
    +
    +
    +
      + {{#gapImgs}}{{{.}}}{{/gapImgs}} +
    +
    +
    diff --git a/src/qtiCommonRenderer/tpl/interactions/graphicOrderInteraction.tpl b/src/qtiCommonRenderer/tpl/interactions/graphicOrderInteraction.tpl new file mode 100755 index 00000000..93e10380 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/interactions/graphicOrderInteraction.tpl @@ -0,0 +1,9 @@ +
    + {{#if prompt}}{{{prompt}}}{{/if}} +
    +
    +
    +
    +
      +
      +
      diff --git a/src/qtiCommonRenderer/tpl/interactions/hotspotInteraction.tpl b/src/qtiCommonRenderer/tpl/interactions/hotspotInteraction.tpl new file mode 100755 index 00000000..6b0cb6b3 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/interactions/hotspotInteraction.tpl @@ -0,0 +1,7 @@ +
      + {{#if prompt}}{{{prompt}}}{{/if}} +
      +
      +
      +
      +
      diff --git a/src/qtiCommonRenderer/tpl/interactions/hottextInteraction.tpl b/src/qtiCommonRenderer/tpl/interactions/hottextInteraction.tpl new file mode 100755 index 00000000..4020b58e --- /dev/null +++ b/src/qtiCommonRenderer/tpl/interactions/hottextInteraction.tpl @@ -0,0 +1,5 @@ +
      + {{#if prompt}}{{{prompt}}}{{/if}} +
      +
      {{{body}}}
      +
      diff --git a/src/qtiCommonRenderer/tpl/interactions/inlineChoiceInteraction.tpl b/src/qtiCommonRenderer/tpl/interactions/inlineChoiceInteraction.tpl new file mode 100755 index 00000000..f69522c5 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/interactions/inlineChoiceInteraction.tpl @@ -0,0 +1,10 @@ + diff --git a/src/qtiCommonRenderer/tpl/interactions/matchInteraction.tpl b/src/qtiCommonRenderer/tpl/interactions/matchInteraction.tpl new file mode 100755 index 00000000..c3538e2d --- /dev/null +++ b/src/qtiCommonRenderer/tpl/interactions/matchInteraction.tpl @@ -0,0 +1,30 @@ +
      + {{#if prompt}}{{{prompt}}}{{/if}} +
      +
      + + + + + {{#matchSet1}}{{{.}}}{{/matchSet1}} + + + + {{#matchSet2}} + + {{{.}}} + {{#each ../matchSet1}} + + {{/each}} + + {{/matchSet2}} + +
      + +
      +
      +
      +
      diff --git a/src/qtiCommonRenderer/tpl/interactions/mediaInteraction.tpl b/src/qtiCommonRenderer/tpl/interactions/mediaInteraction.tpl new file mode 100755 index 00000000..57eba205 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/interactions/mediaInteraction.tpl @@ -0,0 +1,5 @@ +
      + {{#if prompt}}{{{prompt}}}{{/if}} +
      +
      +
      diff --git a/src/qtiCommonRenderer/tpl/interactions/orderInteraction.tpl b/src/qtiCommonRenderer/tpl/interactions/orderInteraction.tpl new file mode 100755 index 00000000..9943f844 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/interactions/orderInteraction.tpl @@ -0,0 +1,24 @@ +
      + {{#if prompt}}{{{prompt}}}{{/if}} +
      +
      +
        + {{#choices}}{{{.}}}{{/choices}} +
      +
      + + +
      +
        +
          +
        +
        + + +
        +
        +
        +
        diff --git a/src/qtiCommonRenderer/tpl/interactions/prompt.tpl b/src/qtiCommonRenderer/tpl/interactions/prompt.tpl new file mode 100755 index 00000000..af21cad2 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/interactions/prompt.tpl @@ -0,0 +1,3 @@ +
        +
        {{{body}}}
        +
        diff --git a/src/qtiCommonRenderer/tpl/interactions/selectPointInteraction.tpl b/src/qtiCommonRenderer/tpl/interactions/selectPointInteraction.tpl new file mode 100755 index 00000000..6fbfefc3 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/interactions/selectPointInteraction.tpl @@ -0,0 +1,7 @@ +
        + {{#if prompt}}{{{prompt}}}{{/if}} +
        +
        +
        +
        +
        diff --git a/src/qtiCommonRenderer/tpl/interactions/sliderInteraction.tpl b/src/qtiCommonRenderer/tpl/interactions/sliderInteraction.tpl new file mode 100755 index 00000000..32a8c00f --- /dev/null +++ b/src/qtiCommonRenderer/tpl/interactions/sliderInteraction.tpl @@ -0,0 +1,4 @@ +
        + {{#if prompt}}{{{prompt}}}{{/if}} +
        +
        diff --git a/src/qtiCommonRenderer/tpl/interactions/textEntryInteraction.tpl b/src/qtiCommonRenderer/tpl/interactions/textEntryInteraction.tpl new file mode 100755 index 00000000..fbc90e46 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/interactions/textEntryInteraction.tpl @@ -0,0 +1 @@ + diff --git a/src/qtiCommonRenderer/tpl/interactions/uploadInteraction.tpl b/src/qtiCommonRenderer/tpl/interactions/uploadInteraction.tpl new file mode 100644 index 00000000..38ac40c9 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/interactions/uploadInteraction.tpl @@ -0,0 +1,17 @@ +
        + {{#if prompt}}{{{prompt}}}{{/if}} +
        +
        +
        + + + +
        +
        +

        {{__ 'No preview available'}}

        +
        + +
        diff --git a/src/qtiCommonRenderer/tpl/item.tpl b/src/qtiCommonRenderer/tpl/item.tpl new file mode 100755 index 00000000..e464afd5 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/item.tpl @@ -0,0 +1,4 @@ +
        + {{{body}}}
        +
        + diff --git a/src/qtiCommonRenderer/tpl/math.tpl b/src/qtiCommonRenderer/tpl/math.tpl new file mode 100755 index 00000000..3f835fc7 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/math.tpl @@ -0,0 +1 @@ +{{#if block}}{{{raw}}}{{else}}{{{raw}}}{{/if}} \ No newline at end of file diff --git a/src/qtiCommonRenderer/tpl/modalFeedback.tpl b/src/qtiCommonRenderer/tpl/modalFeedback.tpl new file mode 100644 index 00000000..67153eb1 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/modalFeedback.tpl @@ -0,0 +1,4 @@ +
        + {{#if attributes.title}}{{/if}} + +
        \ No newline at end of file diff --git a/src/qtiCommonRenderer/tpl/notification.tpl b/src/qtiCommonRenderer/tpl/notification.tpl new file mode 100755 index 00000000..d864ee81 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/notification.tpl @@ -0,0 +1,4 @@ + diff --git a/src/qtiCommonRenderer/tpl/object.tpl b/src/qtiCommonRenderer/tpl/object.tpl new file mode 100755 index 00000000..d39e9e19 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/object.tpl @@ -0,0 +1,2 @@ +
        +
        diff --git a/src/qtiCommonRenderer/tpl/portableInfoControl.tpl b/src/qtiCommonRenderer/tpl/portableInfoControl.tpl new file mode 100644 index 00000000..389dd050 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/portableInfoControl.tpl @@ -0,0 +1,3 @@ +
        + {{{markup}}} +
        \ No newline at end of file diff --git a/src/qtiCommonRenderer/tpl/printedVariable.tpl b/src/qtiCommonRenderer/tpl/printedVariable.tpl new file mode 100644 index 00000000..d7df0927 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/printedVariable.tpl @@ -0,0 +1,3 @@ + + {{value}} + \ No newline at end of file diff --git a/src/qtiCommonRenderer/tpl/rubricBlock.tpl b/src/qtiCommonRenderer/tpl/rubricBlock.tpl new file mode 100644 index 00000000..5e4a3696 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/rubricBlock.tpl @@ -0,0 +1,7 @@ +{{#unless empty}} +
        +
        +
        {{{body}}}
        +
        +
        +{{/unless}} \ No newline at end of file diff --git a/src/qtiCommonRenderer/tpl/stylesheet.tpl b/src/qtiCommonRenderer/tpl/stylesheet.tpl new file mode 100644 index 00000000..73dc21e5 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/stylesheet.tpl @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/src/qtiCommonRenderer/tpl/table.tpl b/src/qtiCommonRenderer/tpl/table.tpl new file mode 100644 index 00000000..084bcf5f --- /dev/null +++ b/src/qtiCommonRenderer/tpl/table.tpl @@ -0,0 +1,9 @@ + + {{{body}}} +
        diff --git a/src/qtiCommonRenderer/tpl/tooltip.tpl b/src/qtiCommonRenderer/tpl/tooltip.tpl new file mode 100644 index 00000000..33cf2705 --- /dev/null +++ b/src/qtiCommonRenderer/tpl/tooltip.tpl @@ -0,0 +1,8 @@ + + {{{body}}} + diff --git a/src/qtiItem/core/Container.js b/src/qtiItem/core/Container.js new file mode 100644 index 00000000..184c5608 --- /dev/null +++ b/src/qtiItem/core/Container.js @@ -0,0 +1,200 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + */ +import $ from 'jquery'; +import _ from 'lodash'; +import Element from 'taoQtiItem/qtiItem/core/Element'; +import rendererConfig from 'taoQtiItem/qtiItem/helper/rendererConfig'; + +var Container = Element.extend({ + qtiClass: '_container', + init: function(body) { + this._super(); //generate serial, attributes array always empty + if (body && typeof body !== 'string') { + throw 'the body of a container must be a string'; + } + this.bdy = body || ''; + this.elements = {}; + }, + body: function(body) { + if (typeof body === 'undefined') { + return this.bdy; + } else { + if (typeof body === 'string') { + this.bdy = body; + $(document).trigger('containerBodyChange', { + body: body, + container: this, + parent: this.parent() + }); + } else { + throw 'body must be a string'; + } + } + }, + setElements: function(elements, body) { + var returnValue = false; + + for (var i in elements) { + var elt = elements[i]; + if (elt instanceof Element) { + body = body || this.bdy; + if (body.indexOf(elt.placeholder()) === -1) { + body += elt.placeholder(); //append the element if no placeholder found + } + + elt.setRootElement(this.getRootElement() || null); + this.elements[elt.getSerial()] = elt; + $(document).trigger('containerElementAdded', { + element: elt, + container: this + }); + + returnValue = true; + } else { + returnValue = false; + throw 'expected a qti element'; + } + } + + this.body(body); + + return returnValue; + }, + setElement: function(element, body) { + return this.setElements([element], body); + }, + removeElement: function(element) { + var serial = ''; + if (typeof element === 'string') { + serial = element; + } else if (element instanceof Element) { + serial = element.getSerial(); + } + delete this.elements[serial]; + this.body(this.body().replace('{{' + serial + '}}', '')); + return this; + }, + getElements: function(qtiClass) { + var elts = {}; + if (typeof qtiClass === 'string') { + for (var serial in this.elements) { + if (Element.isA(this.elements[serial], qtiClass)) { + elts[serial] = this.elements[serial]; + } + } + } else { + elts = _.clone(this.elements); + } + return elts; + }, + getElement: function(serial) { + return this.elements[serial] ? this.elements[serial] : null; + }, + getComposingElements: function() { + var elements = this.getElements(); + var elts = {}; + for (var serial in elements) { + elts[serial] = elements[serial]; //pass individual object by ref, instead of the whole list(object) + elts = _.extend(elts, elements[serial].getComposingElements()); + } + return elts; + }, + render: function() { + var args = rendererConfig.getOptionsFromArguments(arguments), + renderer = args.renderer || this.getRenderer(), + elementsData = [], + tpl = this.body(); + + for (var serial in this.elements) { + var elt = this.elements[serial]; + if (typeof elt.render === 'function') { + if (elt.qtiClass === '_container') { + //@todo : container rendering merging, to be tested + tpl = tpl.replace(elt.placeholder(), elt.render(renderer)); + } else { + tpl = tpl.replace(elt.placeholder(), '{{{' + serial + '}}}'); + elementsData[serial] = elt.render(renderer); + } + } else { + throw 'render() is not defined for the qti element: ' + serial; + } + } + + if (renderer.isRenderer) { + return this._super( + { + body: renderer.renderDirect(tpl, elementsData), + contentModel: this.contentModel || 'flow' + }, + renderer, + args.placeholder + ); + } else { + throw 'invalid qti renderer for qti container'; + } + }, + postRender: function(data, altClassName, renderer) { + renderer = renderer || this.getRenderer(); + + var res = _(this.elements) + .filter(function(elt) { + return typeof elt.postRender === 'function'; + }) + .map(function(elt) { + return elt.postRender(data, '', renderer); + }) + .flatten(true) + .value() + .concat(this._super(data, altClassName, renderer)); + return res; + }, + toArray: function() { + var arr = { + serial: this.serial, + body: this.bdy, + elements: {} + }; + + for (var serial in this.elements) { + arr.elements[serial] = this.elements[serial].toArray(); + } + + return arr; + }, + find: function(serial, parent) { + var found = null; + + if (this.elements[serial]) { + found = { parent: parent || this, element: this.elements[serial], location: 'body' }; + } else { + _.each(this.elements, function(elt) { + found = elt.find(serial); + if (found) { + return false; //break loop + } + }); + } + + return found; + }, + isEmpty: function() { + return !this.bdy; + } +}); + +export default Container; diff --git a/src/qtiItem/core/Element.js b/src/qtiItem/core/Element.js new file mode 100644 index 00000000..9b1f139b --- /dev/null +++ b/src/qtiItem/core/Element.js @@ -0,0 +1,466 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2015 (original work) Open Assessment Technologies SA + * + */ +import $ from 'jquery'; +import _ from 'lodash'; +import Class from 'class'; +import loggerFactory from 'core/logger'; +import util from 'taoQtiItem/qtiItem/helper/util'; +import rendererConfig from 'taoQtiItem/qtiItem/helper/rendererConfig'; + +var _instances = {}; + +/** + * Create a logger + */ +var logger = loggerFactory('taoQtiItem/qtiItem/core/Element'); + +var Element = Class.extend({ + qtiClass: '', + serial: '', + rootElement: null, + init: function(serial, attributes) { + //init own attributes + this.attributes = {}; + + //system properties, for item creator internal use only + this.metaData = {}; + + //init call in the format init(attributes) + if (typeof serial === 'object') { + attributes = serial; + serial = ''; + } + + if (!serial) { + serial = util.buildSerial(this.qtiClass + '_'); + } + + if (serial && (typeof serial !== 'string' || !serial.match(/^[a-z_0-9]*$/i))) { + throw 'invalid QTI serial : (' + typeof serial + ') ' + serial; + } + + if (!_instances[serial]) { + _instances[serial] = this; + this.serial = serial; + this.setAttributes(attributes || {}); + } else { + throw 'a QTI Element with the same serial already exists ' + serial; + } + + if (typeof this.initContainer === 'function') { + this.initContainer(arguments[2] || ''); + } + if (typeof this.initObject === 'function') { + this.initObject(); + } + }, + is: function(qtiClass) { + return qtiClass === this.qtiClass; + }, + placeholder: function() { + return '{{' + this.serial + '}}'; + }, + getSerial: function() { + return this.serial; + }, + getUsedIdentifiers: function() { + var usedIds = {}; + var elts = this.getComposingElements(); + for (var i in elts) { + var elt = elts[i]; + var id = elt.attr('identifier'); + if (id) { + //warning: simplistic implementation, allow only one unique identifier in the item no matter the element class/type + usedIds[id] = elt; + } + } + return usedIds; + }, + + /** + * Get the ids in use. (id is different from identifier) + * @returns {Array} of the ids in use + */ + getUsedIds: function getUsedIds() { + var usedIds = []; + _.forEach(this.getComposingElements(), function(elt) { + var id = elt.attr('id'); + if (id && !_.contains(usedIds, id)) { + usedIds.push(id); + } + }); + return usedIds; + }, + + attr: function(name, value) { + if (name) { + if (value !== undefined) { + this.attributes[name] = value; + } else { + if (typeof name === 'object') { + for (var prop in name) { + this.attr(prop, name[prop]); + } + } else if (typeof name === 'string') { + if (this.attributes[name] === undefined) { + return undefined; + } else { + return this.attributes[name]; + } + } + } + } + return this; + }, + data: function(name, value) { + if (name) { + if (value !== undefined) { + this.metaData[name] = value; + $(document).trigger('metaChange.qti-widget', { element: this, key: name, value: value }); + } else { + if (typeof name === 'object') { + for (var prop in name) { + this.data(prop, name[prop]); + } + } else if (typeof name === 'string') { + if (this.metaData[name] === undefined) { + return undefined; + } else { + return this.metaData[name]; + } + } + } + } + return this; + }, + removeData: function(name) { + delete this.metaData[name]; + return this; + }, + removeAttr: function(name) { + return this.removeAttributes(name); + }, + setAttributes: function(attributes) { + if (!_.isPlainObject(attributes)) { + logger.warn('attributes should be a plain object'); + } + this.attributes = attributes; + return this; + }, + getAttributes: function() { + return _.clone(this.attributes); + }, + removeAttributes: function(attrNames) { + if (typeof attrNames === 'string') { + attrNames = [attrNames]; + } + for (var i in attrNames) { + delete this.attributes[attrNames[i]]; + } + return this; + }, + getComposingElements: function() { + var elts = {}; + function append(element) { + elts[element.getSerial()] = element; //pass individual object by ref, instead of the whole list(object) + elts = _.extend(elts, element.getComposingElements()); + } + if (typeof this.initContainer === 'function') { + append(this.getBody()); + } + if (typeof this.initObject === 'function') { + append(this.getObject()); + } + _.each(this.metaData, function(v) { + if (Element.isA(v, '_container')) { + append(v); + } else if (_.isArray(v)) { + _.each(v, function(v) { + if (Element.isA(v, '_container')) { + append(v); + } + }); + } + }); + return elts; + }, + getUsedClasses: function() { + var ret = [this.qtiClass], + composingElts = this.getComposingElements(); + + _.each(composingElts, function(elt) { + ret.push(elt.qtiClass); + }); + + return _.uniq(ret); + }, + find: function(serial) { + var found = null; + var object, body; + + if (typeof this.initObject === 'function') { + object = this.getObject(); + if (object.serial === serial) { + found = { parent: this, element: object, location: 'object' }; + } + } + + if (!found && typeof this.initContainer === 'function') { + body = this.getBody(); + if (body.serial === serial) { + found = { parent: this, element: body, location: 'body' }; + } else { + found = this.getBody().find(serial, this); + } + } + + return found; + }, + parent: function() { + var item = this.getRootElement(); + if (item) { + var found = item.find(this.getSerial()); + if (found) { + return found.parent; + } + } + return null; + }, + /** + * @deprecated - use setRootElement() instead + */ + setRelatedItem: function(item) { + logger.warn('Deprecated use of setRelatedItem()'); + this.setRootElement(item); + }, + setRootElement: function(item) { + var composingElts, i; + + if (Element.isA(item, 'assessmentItem')) { + this.rootElement = item; + composingElts = this.getComposingElements(); + for (i in composingElts) { + composingElts[i].setRootElement(item); + } + } + }, + /** + * @deprecated - use getRootElement() instead + */ + getRelatedItem: function() { + logger.warn('Deprecated use of getRelatedItem()'); + return this.getRootElement(); + }, + getRootElement: function() { + var ret = null; + if (Element.isA(this.rootElement, 'assessmentItem')) { + ret = this.rootElement; + } + return ret; + }, + setRenderer: function(renderer) { + if (renderer && renderer.isRenderer) { + this.renderer = renderer; + var elts = this.getComposingElements(); + for (var serial in elts) { + elts[serial].setRenderer(renderer); + } + } else { + throw 'invalid qti rendering engine'; + } + }, + getRenderer: function() { + return this.renderer; + }, + /** + * Render the element. Arguments are all optional and can be given in any order. + * Argument parsing is based on argument type and is done by taoQtiItem/qtiItem/core/helpers/rendererConfig + * @param {Renderer} renderer - specify which renderer to use + * @param {jQuery} placeholder - DOM element that will be replaced by the rendered element + * @param {Object} data - template data for the rendering + * @param {String} subclass - subclass enables different behaviour of the same qti class in different contexts (eg. we could have different rendering for simpleChoice according to where it is being used: simpleChoice.orderInteraction, simpleChoice.choiceInteraction...) + * @returns {String} - the rendered element as an HTML string + */ + render: function render() { + var args = rendererConfig.getOptionsFromArguments(arguments); + var _renderer = args.renderer || this.getRenderer(); + var rendering; + + var tplData = {}, + defaultData = { + tag: this.qtiClass, + serial: this.serial, + attributes: this.getAttributes() + }; + + if (!_renderer) { + throw new Error('render: no renderer found for the element ' + this.qtiClass + ':' + this.serial); + } + + if (typeof this.initContainer === 'function') { + //allow body to have a different renderer if it has its own renderer set + defaultData.body = this.getBody().render(args.renderer); + } + if (typeof this.initObject === 'function') { + defaultData.object = { + attributes: this.object.getAttributes() + }; + defaultData.object.attributes.data = _renderer.resolveUrl(this.object.attr('data')); + } + + tplData = _.merge(defaultData, args.data || {}); + tplData = _renderer.getData(this, tplData, args.subclass); + rendering = _renderer.renderTpl(this, tplData, args.subclass); + if (args.placeholder) { + args.placeholder.replaceWith(rendering); + } + + return rendering; + }, + postRender: function(data, altClassName, renderer) { + var postRenderers = []; + var _renderer = renderer || this.getRenderer(); + + if (typeof this.initContainer === 'function') { + //allow body to have a different renderer if it has its own renderer set + postRenderers = this.getBody().postRender(data, '', renderer); + } + + if (_renderer) { + postRenderers.push(_renderer.postRender(this, data, altClassName)); + } else { + throw 'postRender: no renderer found for the element ' + this.qtiClass + ':' + this.serial; + } + + return _.compact(postRenderers); + }, + getContainer: function($scope, subclass) { + var renderer = this.getRenderer(); + if (renderer) { + return renderer.getContainer(this, $scope, subclass); + } else { + throw 'getContainer: no renderer found for the element ' + this.qtiClass + ':' + this.serial; + } + }, + toArray: function() { + var arr = { + serial: this.serial, + type: this.qtiClass, + attributes: this.getAttributes() + }; + + if (typeof this.initContainer === 'function') { + arr.body = this.getBody().toArray(); + } + if (typeof this.initObject === 'function') { + arr.object = this.object.toArray(); + } + + return arr; + }, + isEmpty: function() { + //tells whether the element should be considered empty or not, from the rendering point of view + return false; + }, + addClass: function(className) { + var clazz = this.attr('class') || ''; + if (!_containClass(clazz, className)) { + this.attr('class', clazz + (clazz.length ? ' ' : '') + className); + } + }, + hasClass: function(className) { + return _containClass(this.attr('class'), className); + }, + removeClass: function(className) { + var clazz = this.attr('class') || ''; + if (clazz) { + var regex = new RegExp('(?:^|\\s)' + className + '(?:\\s|$)'); + clazz = clazz.replace(regex, ' ').trim(); + + if (clazz) { + this.attr('class', clazz); + } else { + this.removeAttr('class'); + } + } + }, + /** + * Add or remove class. Setting the optional state will force addition/removal. + * State is here to keep the jQuery syntax + * + * @param {String} className + * @param {Boolean} [state] + */ + toggleClass: function(className, state) { + if (typeof state === 'boolean') { + return state ? this.addClass(className) : this.removeClass(className); + } + + if (this.hasClass(className)) { + return this.removeClass(className); + } + return this.addClass(className); + }, + isset: function() { + return Element.issetElement(this.serial); + }, + unset: function() { + return Element.unsetElement(this.serial); + } +}); + +var _containClass = function(allClassStr, className) { + var regex = new RegExp('(?:^|\\s)' + className + '(?:\\s|$)', ''); + return allClassStr && regex.test(allClassStr); +}; + +//helpers +Element.isA = function(qtiElement, qtiClass) { + return qtiElement instanceof Element && qtiElement.is(qtiClass); +}; + +Element.getElementBySerial = function(serial) { + return _instances[serial]; +}; + +Element.issetElement = function(serial) { + return !!_instances[serial]; +}; + +/** + * Unset a registered element from it's serial + * @param {String} serial - the element serial + * @returns {Boolean} true if unset + */ +Element.unsetElement = function(serial) { + var element = Element.getElementBySerial(serial); + + if (element) { + var composingElements = element.getComposingElements(); + _.each(composingElements, function(elt) { + delete _instances[elt.serial]; + }); + delete _instances[element.serial]; + + return true; + } + return false; +}; + +export default Element; diff --git a/src/qtiItem/core/IdentifiedElement.js b/src/qtiItem/core/IdentifiedElement.js new file mode 100644 index 00000000..539a66b7 --- /dev/null +++ b/src/qtiItem/core/IdentifiedElement.js @@ -0,0 +1,63 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2015 (original work) Open Assessment Technologies SA + * + */ + +/** + * IdentifiedElement model + * @author Sam Sipasseuth + * @author Bertrand Chevrier + */ +import Element from 'taoQtiItem/qtiItem/core/Element'; +import util from 'taoQtiItem/qtiItem/helper/util'; + +/** + * IdentifiedElement model + */ +var IdentifiedElement = Element.extend({ + /** + * Generates and assign an identifier + * @param {String} prefix - identifier prefix + * @param {Boolean} [useSuffix = true] - add a "_ + index" to the identifier + * @returns {Object} for chaining + */ + buildIdentifier: function buildIdentifier(prefix, useSuffix) { + var item = this.getRootElement(); + var id = util.buildIdentifier(item, prefix || this.qtiClass, useSuffix); + if (id) { + this.attr('identifier', id); + } + return this; + }, + + /** + * Get/set and identifier. It will be generated if it doesn't exists. + * @param {String} [value] - set the value or get it if not set. + * @returns {String} the identifier + */ + id: function id(value) { + if (!value && !this.attr('identifier')) { + this.buildIdentifier(this.qtiClass, true); + } + return this.attr('identifier', value); + } +}); + +/** + * @exports taoQtiItem/qtiItem/core/IdentifiableElement + */ +export default IdentifiedElement; diff --git a/src/qtiItem/core/Img.js b/src/qtiItem/core/Img.js new file mode 100644 index 00000000..46d7b1eb --- /dev/null +++ b/src/qtiItem/core/Img.js @@ -0,0 +1,23 @@ +import Element from 'taoQtiItem/qtiItem/core/Element'; +import _ from 'lodash'; +import rendererConfig from 'taoQtiItem/qtiItem/helper/rendererConfig'; + +var Img = Element.extend({ + qtiClass: 'img', + render: function() { + var args = rendererConfig.getOptionsFromArguments(arguments), + renderer = args.renderer || this.getRenderer(), + defaultData = {}; + + defaultData.attributes = { + src: renderer.resolveUrl(this.attr('src')) + }; + + return this._super(_.merge(defaultData, args.data), args.placeholder, args.subclass, renderer); + }, + isEmpty: function() { + return !this.attr('src'); + } +}); + +export default Img; diff --git a/src/qtiItem/core/Include.js b/src/qtiItem/core/Include.js new file mode 100644 index 00000000..da6b4da2 --- /dev/null +++ b/src/qtiItem/core/Include.js @@ -0,0 +1,16 @@ +import Element from 'taoQtiItem/qtiItem/core/Element'; +import Container from 'taoQtiItem/qtiItem/mixin/ContainerInline'; +import NamespacedElement from 'taoQtiItem/qtiItem/mixin/NamespacedElement'; + +var Include = Element.extend({ + qtiClass: 'include', + defaultNsName: 'xi', + defaultNsUri: 'http://www.w3.org/2001/XInclude', + nsUriFragment: 'XInclude', + isEmpty: function() { + return !this.attr('href') || this.getBody().isEmpty(); + } +}); +Container.augment(Include); +NamespacedElement.augment(Include); +export default Include; diff --git a/src/qtiItem/core/Item.js b/src/qtiItem/core/Item.js new file mode 100644 index 00000000..7de744d7 --- /dev/null +++ b/src/qtiItem/core/Item.js @@ -0,0 +1,242 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ + +/** + * QTI Item Element model + * + * @author Sam Sipasseuth + * @author Bertrand Chevrier + */ +import Element from 'taoQtiItem/qtiItem/core/Element'; +import IdentifiedElement from 'taoQtiItem/qtiItem/core/IdentifiedElement'; +import Container from 'taoQtiItem/qtiItem/mixin/ContainerItemBody'; +import _ from 'lodash'; +import $ from 'jquery'; +import util from 'taoQtiItem/qtiItem/helper/util'; + +var Item = IdentifiedElement.extend({ + qtiClass: 'assessmentItem', + init: function init(serial, attributes) { + this._super(serial, attributes); + this.rootElement = this; + this.stylesheets = {}; + this.responses = {}; + this.outcomes = {}; + this.modalFeedbacks = {}; + this.namespaces = {}; + this.schemaLocations = {}; + this.responseProcessing = null; + this.apipAccessibility = null; + }, + getInteractions: function getInteractions() { + var interactions = []; + var elts = this.getComposingElements(); + for (var serial in elts) { + if (Element.isA(elts[serial], 'interaction')) { + interactions.push(elts[serial]); + } + } + return interactions; + }, + addResponseDeclaration: function addResponseDeclaration(response) { + if (Element.isA(response, 'responseDeclaration')) { + response.setRootElement(this); + this.responses[response.getSerial()] = response; + } else { + throw 'is not a qti response declaration'; + } + return this; + }, + getResponseDeclaration: function getResponseDeclaration(identifier) { + for (var i in this.responses) { + if (this.responses[i].attr('identifier') === identifier) { + return this.responses[i]; + } + } + return null; + }, + addOutcomeDeclaration: function addOutcomeDeclaration(outcome) { + if (Element.isA(outcome, 'outcomeDeclaration')) { + outcome.setRootElement(this); + this.outcomes[outcome.getSerial()] = outcome; + } else { + throw 'is not a qti outcome declaration'; + } + return this; + }, + getOutcomeDeclaration: function getOutcomeDeclaration(identifier) { + var found; + _.forEach(this.outcomes, function(outcome) { + if (outcome.id() === identifier) { + found = outcome; + return false; + } + }); + return found; + }, + getOutcomes: function getOutcomes() { + return _.clone(this.outcomes); + }, + removeOutcome: function removeOutcome(identifier) { + var outcome = this.getOutcomeDeclaration(identifier); + if (outcome) { + this.outcomes = _.omit(this.outcomes, outcome.getSerial()); + } + }, + addModalFeedback: function addModalFeedback(feedback) { + if (Element.isA(feedback, 'modalFeedback')) { + feedback.setRootElement(this); + this.modalFeedbacks[feedback.getSerial()] = feedback; + } else { + throw 'is not a qti modal feedback'; + } + return this; + }, + getComposingElements: function getComposingElements() { + var elts = this._super(), + _this = this; + _.forEach(['responses', 'outcomes', 'modalFeedbacks', 'stylesheets'], function(elementCollection) { + for (var i in _this[elementCollection]) { + var elt = _this[elementCollection][i]; + elts[i] = elt; + elts = _.extend(elts, elt.getComposingElements()); + } + }); + if (this.responseProcessing instanceof Element) { + elts[this.responseProcessing.getSerial()] = this.responseProcessing; + } + return elts; + }, + find: function find(serial) { + var found = this._super(serial); + + if (!found) { + found = util.findInCollection(this, ['responses', 'outcomes', 'modalFeedbacks', 'stylesheets'], serial); + } + + return found; + }, + getResponses: function getResponses() { + return _.clone(this.responses); + }, + getRootElement: function getRootElement() { + return this; + }, + addNamespace: function addNamespace(name, uri) { + this.namespaces[name] = uri; + }, + setNamespaces: function setNamespaces(namespaces) { + this.namespaces = namespaces; + }, + getNamespaces: function getNamespaces() { + return _.clone(this.namespaces); + }, + setSchemaLocations: function setSchemaLocations(locations) { + this.schemaLocations = locations; + }, + getSchemaLocations: function getSchemaLocations() { + return _.clone(this.schemaLocations); + }, + setApipAccessibility: function setApipAccessibility(apip) { + this.apipAccessibility = apip || null; + }, + getApipAccessibility: function getApipAccessibility() { + return this.apipAccessibility; + }, + addStylesheet: function addStylesheet(stylesheet) { + if (Element.isA(stylesheet, 'stylesheet')) { + stylesheet.setRootElement(this); + this.stylesheets[stylesheet.getSerial()] = stylesheet; + } else { + throw 'is not a qti stylesheet declaration'; + } + return this; + }, + removeStyleSheet: function removeStyleSheet(stylesheet) { + delete this.stylesheets[stylesheet.getSerial()]; + return this; + }, + stylesheetExists: function stylesheetExists(href) { + var exists = false; + _.forEach(this.stylesheets, function(stylesheet) { + if (stylesheet.attr('href') === href) { + exists = true; + return false; //break each loop + } + }); + return exists; + }, + setResponseProcessing: function setResponseProcessing(rp) { + if (Element.isA(rp, 'responseProcessing')) { + rp.setRootElement(this); + this.responseProcessing = rp; + } else { + throw 'is not a response processing'; + } + return this; + }, + toArray: function toArray() { + var arr = this._super(); + var toArray = function(elt) { + return elt.toArray(); + }; + arr.namespaces = this.namespaces; + arr.schemaLocations = this.schemaLocations; + arr.outcomes = _.map(this.outcomes, toArray); + arr.responses = _.map(this.responses, toArray); + arr.stylesheets = _.map(this.stylesheets, toArray); + arr.modalFeedbacks = _.map(this.modalFeedbacks, toArray); + arr.responseProcessing = this.responseProcessing.toArray(); + return arr; + }, + isEmpty: function isEmpty() { + var body = this.body().trim(); + + if (body) { + //hack to fix #2652 + var $dummy = $('
        ').html(body), + $children = $dummy.children(); + + if ($children.length === 1 && $children.hasClass('empty')) { + return true; + } else { + return false; + } + } else { + return true; + } + }, + + /** + * Clean up an item rendering. + * Ask the renderer to run destroy if exists. + */ + clear: function clear() { + var renderer = this.getRenderer(); + if (renderer) { + if (_.isFunction(renderer.destroy)) { + renderer.destroy(this); + } + } + } +}); + +Container.augment(Item); + +export default Item; diff --git a/src/qtiItem/core/Loader.js b/src/qtiItem/core/Loader.js new file mode 100644 index 00000000..ed620fd1 --- /dev/null +++ b/src/qtiItem/core/Loader.js @@ -0,0 +1,436 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2015 (original work) Open Assessment Technologies SA ; + * + */ +//@todo : move this to the ../helper directory +import _ from 'lodash'; +import Class from 'class'; +import qtiClasses from 'taoQtiItem/qtiItem/core/qtiClasses'; +import Element from 'taoQtiItem/qtiItem/core/Element'; +import xmlNsHandler from 'taoQtiItem/qtiItem/helper/xmlNsHandler'; + +var Loader = Class.extend({ + init: function(item, classesLocation) { + this.qti = {}; //loaded qti classes are store here + this.classesLocation = {}; + + this.item = item || null; //starts either from scratch or with an existing item object + this.setClassesLocation(classesLocation || qtiClasses); //load default location for qti classes model + }, + setClassesLocation: function(qtiClasses) { + _.extend(this.classesLocation, qtiClasses); + return this; + }, + getRequiredClasses: function(data) { + var ret = [], + i; + for (i in data) { + if (i === 'qtiClass' && data[i] !== '_container' && i !== 'rootElement') { + //although a _container is a concrete class in TAO, it is not defined in QTI standard + ret.push(data[i]); + } else if (typeof data[i] === 'object' && i !== 'responseRules') { + //responseRules should'nt be part of the parsing + ret = _.union(ret, this.getRequiredClasses(data[i])); + } + } + return ret; + }, + loadRequiredClasses: function(data, callback, reload) { + var i; + var requiredClass, + requiredClasses = this.getRequiredClasses(data, reload), + required = []; + + for (i in requiredClasses) { + requiredClass = requiredClasses[i]; + if (this.classesLocation[requiredClass]) { + required.push(this.classesLocation[requiredClass]); + } else { + throw new Error('missing qti class location declaration : ' + requiredClass); + } + } + + var _this = this; + require(required, function() { + _.each(arguments, function(QtiClass) { + _this.qti[QtiClass.prototype.qtiClass] = QtiClass; + }); + callback.call(_this, _this.qti); + }); + }, + getLoadedClasses: function() { + return _.keys(this.qti); + }, + loadItemData: function(data, callback) { + var _this = this; + _this.loadRequiredClasses(data, function(Qti) { + var i; + if (typeof data === 'object' && data.qtiClass === 'assessmentItem') { + //unload an item from it's serial (in case of a reload) + if (data.serial) { + Element.unsetElement(data.serial); + } + + _this.item = new Qti.assessmentItem(data.serial, data.attributes || {}); + _this.loadContainer(_this.item.getBody(), data.body); + + for (i in data.outcomes) { + var outcome = _this.buildOutcome(data.outcomes[i]); + if (outcome) { + _this.item.addOutcomeDeclaration(outcome); + } + } + for (i in data.feedbacks) { + var feedback = _this.buildElement(data.feedbacks[i]); + if (feedback) { + _this.item.addModalFeedback(feedback); + } + } + for (i in data.stylesheets) { + var stylesheet = _this.buildElement(data.stylesheets[i]); + if (stylesheet) { + _this.item.addStylesheet(stylesheet); + } + } + + //important : build responses after all modal feedbacks and outcomes has been loaded, because the simple feedback rules need to reference them + for (i in data.responses) { + var response = _this.buildResponse(data.responses[i]); + if (response) { + _this.item.addResponseDeclaration(response); + + var feedbackRules = data.responses[i].feedbackRules; + if (feedbackRules) { + _.forIn(feedbackRules, function(fbData, serial) { + response.feedbackRules[serial] = _this.buildSimpleFeedbackRule(fbData, response); + }); + } + } + } + + if (data.responseProcessing) { + _this.item.setResponseProcessing(_this.buildResponseProcessing(data.responseProcessing)); + } + _this.item.setNamespaces(data.namespaces); + _this.item.setSchemaLocations(data.schemaLocations); + _this.item.setApipAccessibility(data.apipAccessibility); + } + + if (typeof callback === 'function') { + callback.call(_this, _this.item); + } + }); + }, + loadAndBuildElement: function(data, callback) { + var _this = this; + + _this.loadRequiredClasses(data, function(Qti) { + var element = _this.buildElement(data); + + if (typeof callback === 'function') { + callback.call(_this, element); + } + }); + }, + loadElement: function(element, data, callback) { + var _this = this; + this.loadRequiredClasses(data, function() { + _this.loadElementData(element, data); + if (typeof callback === 'function') { + callback.call(_this, element); + } + }); + }, + /** + * Load ALL given elements into existing loaded item + * + * @todo to be renamed to loadItemElements + * @param {object} data + * @param {function} callback + * @returns {undefined} + */ + loadElements: function(data, callback) { + var _this = this; + + if (_this.item) { + this.loadRequiredClasses(data, function() { + var allElements = _this.item.getComposingElements(); + + for (var i in data) { + var elementData = data[i]; + if (elementData && elementData.qtiClass && elementData.serial) { + //find and update element + if (allElements[elementData.serial]) { + _this.loadElementData(allElements[elementData.serial], elementData); + } + } + } + + if (typeof callback === 'function') { + callback.call(_this, _this.item); + } + }); + } else { + throw 'QtiLoader : cannot load elements in empty item'; + } + }, + buildResponse: function(data) { + var response = this.buildElement(data); + + response.template = data.howMatch || null; + response.defaultValue = data.defaultValue || null; + response.correctResponse = data.correctResponses || null; + + if (_.size(data.mapping)) { + response.mapEntries = data.mapping; + } else if (_.size(data.areaMapping)) { + response.mapEntries = data.areaMapping; + } else { + response.mapEntries = {}; + } + + response.mappingAttributes = data.mappingAttributes || {}; + + return response; + }, + buildSimpleFeedbackRule: function(data, response) { + var feedbackRule = this.buildElement(data); + + feedbackRule.setCondition(response, data.condition, data.comparedValue || null); + + // feedbackRule.comparedOutcome = this.item.responses[data.comparedOutcome] || null; + feedbackRule.feedbackOutcome = this.item.outcomes[data.feedbackOutcome] || null; + feedbackRule.feedbackThen = this.item.modalFeedbacks[data.feedbackThen] || null; + feedbackRule.feedbackElse = this.item.modalFeedbacks[data.feedbackElse] || null; + + //associate the compared outcome to the feedbacks if applicable + var response = feedbackRule.comparedOutcome; + if (feedbackRule.feedbackThen) { + feedbackRule.feedbackThen.data('relatedResponse', response); + } + if (feedbackRule.feedbackElse) { + feedbackRule.feedbackElse.data('relatedResponse', response); + } + + return feedbackRule; + }, + buildOutcome: function(data) { + var outcome = this.buildElement(data); + outcome.defaultValue = data.defaultValue || null; + return outcome; + }, + buildResponseProcessing: function(data) { + var rp = this.buildElement(data); + if (data && data.processingType) { + if (data.processingType === 'custom') { + rp.xml = data.data; + rp.processingType = 'custom'; + } else { + rp.processingType = 'templateDriven'; + } + } + return rp; + }, + loadContainer: function(bodyObject, bodyData) { + if (!Element.isA(bodyObject, '_container')) { + throw 'bodyObject must be a QTI Container'; + } + + if ( + bodyData && + typeof bodyData.body === 'string' && + (typeof bodyData.elements === 'array' || typeof bodyData.elements === 'object') + ) { + for (var serial in bodyData.elements) { + var eltData = bodyData.elements[serial]; + //check if class is loaded: + var element = this.buildElement(eltData); + if (element) { + bodyObject.setElement(element, bodyData.body); + } + } + bodyObject.body(xmlNsHandler.stripNs(bodyData.body)); + } else { + throw 'wrong bodydata format'; + } + }, + buildElement: function(elementData) { + var elt = null; + if (elementData && elementData.qtiClass && elementData.serial) { + var className = elementData.qtiClass; + if (this.qti[className]) { + elt = new this.qti[className](elementData.serial); + this.loadElementData(elt, elementData); + } else { + throw 'the qti element class does not exist: ' + className; + } + } else { + throw 'wrong elementData format'; + } + return elt; + }, + loadElementData: function(element, data) { + //merge attributes when loading element data + var attributes = _.defaults(data.attributes || {}, element.attributes || {}); + element.setAttributes(attributes); + + if (element.body && data.body) { + if (element.bdy) { + this.loadContainer(element.getBody(), data.body); + } + } + + if (element.object && data.object) { + if (element.object) { + this.loadObjectData(element.object, data.object); + } + } + + if (Element.isA(element, 'interaction')) { + this.loadInteractionData(element, data); + } else if (Element.isA(element, 'choice')) { + this.loadChoiceData(element, data); + } else if (Element.isA(element, 'math')) { + this.loadMathData(element, data); + } else if (Element.isA(element, 'infoControl')) { + this.loadPicData(element, data); + } else if (Element.isA(element, '_tooltip')) { + this.loadTooltipData(element, data); + } + + return element; + }, + loadInteractionData: function(interaction, data) { + if (Element.isA(interaction, 'blockInteraction')) { + if (data.prompt) { + this.loadContainer(interaction.prompt.getBody(), data.prompt); + } + } + + this.buildInteractionChoices(interaction, data); + + if (Element.isA(interaction, 'customInteraction')) { + this.loadPciData(interaction, data); + } + }, + buildInteractionChoices: function(interaction, data) { + //note: Qti.ContainerInteraction (Qti.GapMatchInteraction and Qti.HottextInteraction) has already been parsed by builtElement(interacionData); + if (data.choices) { + if (Element.isA(interaction, 'matchInteraction')) { + for (var set = 0; set < 2; set++) { + if (!data.choices[set]) { + throw 'missing match set #' + set; + } + var matchSet = data.choices[set]; + for (var serial in matchSet) { + var choice = this.buildElement(matchSet[serial]); + if (choice) { + interaction.addChoice(choice, set); + } + } + } + } else { + for (var serial in data.choices) { + var choice = this.buildElement(data.choices[serial]); + if (choice) { + interaction.addChoice(choice); + } + } + } + + if (Element.isA(interaction, 'graphicGapMatchInteraction')) { + if (data.gapImgs) { + for (var serial in data.gapImgs) { + var gapImg = this.buildElement(data.gapImgs[serial]); + if (gapImg) { + interaction.addGapImg(gapImg); + } + } + } + } + } + }, + loadChoiceData: function(choice, data) { + if (Element.isA(choice, 'textVariableChoice')) { + choice.val(data.text); + } else if (Element.isA(choice, 'gapImg')) { + //has already been taken care of in buildElement() + } else if (Element.isA(choice, 'gapText')) { + // this ensure compatibility of Qti 2.1 items + if (!choice.body()) { + choice.body(data.text); + } + } else if (Element.isA(choice, 'containerChoice')) { + //has already been taken care of in buildElement() + } + }, + loadObjectData: function(object, data) { + object.setAttributes(data.attributes); + //@todo: manage object like a container + if (data._alt) { + if (data._alt.qtiClass === 'object') { + object._alt = Loader.buildElement(data._alt); + } else { + object._alt = data._alt; + } + } + }, + loadMathData: function(math, data) { + math.ns = data.ns || {}; + math.setMathML(data.mathML || ''); + _.forIn(data.annotations || {}, function(value, encoding) { + math.setAnnotation(encoding, value); + }); + }, + loadTooltipData: function(tooltip, data) { + tooltip.content(data.content); + }, + loadPciData: function(pci, data) { + loadPortableCustomElementData(pci, data); + }, + loadPicData: function(pic, data) { + loadPortableCustomElementData(pic, data); + } +}); + +function loadPortableCustomElementData(portableElement, data) { + portableElement.typeIdentifier = data.typeIdentifier; + portableElement.markup = data.markup; + portableElement.entryPoint = data.entryPoint; + portableElement.libraries = data.libraries; + portableElement.setNamespace('', data.xmlns); + + loadPortableCustomElementProperties(portableElement, data.properties); +} + +/** + * If a property is given as a serialized JSON object, parse it directly to a JS object + */ +function loadPortableCustomElementProperties(portableElement, rawProperties) { + var properties = {}; + + _.forOwn(rawProperties, function(value, key) { + try { + properties[key] = JSON.parse(value); + } catch (e) { + properties[key] = value; + } + }); + portableElement.properties = properties; +} + +export default Loader; diff --git a/src/qtiItem/core/Math.js b/src/qtiItem/core/Math.js new file mode 100644 index 00000000..3a235a24 --- /dev/null +++ b/src/qtiItem/core/Math.js @@ -0,0 +1,138 @@ +import $ from 'jquery'; +import _ from 'lodash'; +import Element from 'taoQtiItem/qtiItem/core/Element'; +import rendererConfig from 'taoQtiItem/qtiItem/helper/rendererConfig'; +import NamespacedElement from 'taoQtiItem/qtiItem/mixin/NamespacedElement'; + +var Math; + +/** + * Remove the closing MathML tags and remove useless line breaks before and after it + * + * @param {String} mathML + * @param {String} nsName + * @returns {String} + */ +function _stripMathTags(mathML, nsName) { + var regex = new RegExp('<(/)?' + (nsName ? nsName + ':' : '') + 'math[^>]*>', 'g'); + return mathML + .replace(regex, '') + .replace(/^\s*[\r\n]/gm, '') //remove first blank line + .replace(/\s*[\r\n]$/gm, ''); //last blank line +} + +/** + * Remove mathML ns name prefix from the mathML + * + * @param {String} mathML + * @param {String} nsName + * @returns {String} + */ +function _stripNamespace(mathML, nsName) { + var regex = new RegExp('<(/)?' + (nsName ? nsName + ':' : ''), 'g'); + return mathML.replace(regex, '<$1'); +} + +/** + * Check if the mathML string is to be considered empty + * + * @param {String} mathStr + * @returns {Boolean} + */ +function _isEmptyMathML(mathStr) { + var $math, + mathText, + hasContent = false; + + if (mathStr && mathStr.trim()) { + $math = $($.parseHTML(mathStr)); + mathText = $math.text(); + hasContent = _.isString(mathText) && !!mathText.trim(); + } + + return !hasContent; +} + +Math = Element.extend({ + qtiClass: 'math', + defaultNsName: 'm', + defaultNsUri: 'http://www.w3.org/1998/Math/MathML', + nsUriFragment: 'MathML', + init: function(serial, attributes) { + this._super(serial, attributes); + this.ns = null; + this.mathML = ''; + this.annotations = {}; + }, + setAnnotation: function(encoding, value) { + this.annotations[encoding] = _.unescape(value); + }, + getAnnotation: function(encoding) { + return this.annotations[encoding]; + }, + removeAnnotation: function(encoding) { + delete this.annotations[encoding]; + }, + setMathML: function(mathML) { + var ns = this.getNamespace(), + nsName = ns.name && ns.uri ? ns.name : ''; + + mathML = _stripMathTags(mathML, nsName); + if (ns) { + mathML = _stripNamespace(mathML, nsName); + } + this.mathML = mathML; + }, + getMathML: function() { + return this.mathML; + }, + render: function() { + var args = rendererConfig.getOptionsFromArguments(arguments), + renderer = args.renderer || this.getRenderer(), + tag = this.qtiClass, + raw = this.mathML, + body = raw, + ns = this.getNamespace(), + annotations = '', + encoding, + defaultData; + + for (encoding in this.annotations) { + annotations += + '' + _.escape(this.annotations[encoding]) + ''; + } + + if (annotations) { + if (raw.indexOf('') > 0) { + raw = raw.replace('', annotations + ''); + } else { + raw = '' + raw + annotations + ''; + } + } + + if (ns && ns.name) { + body = raw.replace(/<(\/)?([^!<])/g, '<$1' + ns.name + ':$2'); + tag = ns.name + ':' + tag; + } + + body = body.replace(//g, ''); // remove Mathjax-generated comments + body = body.replace(/<!--.*?-->/g, ''); // fix for broken items because of Mathjax comments + + defaultData = { + block: this.attr('display') === 'block' ? true : false, + body: body, + raw: raw, + tag: tag, + ns: ns + }; + + return this._super(_.merge(defaultData, args.data), args.placeholder, args.subclass, renderer); + }, + isEmpty: function() { + return _isEmptyMathML(this.mathML) && (!this.annotations.latex || !this.annotations.latex.trim()); + } +}); + +NamespacedElement.augment(Math); + +export default Math; diff --git a/src/qtiItem/core/Object.js b/src/qtiItem/core/Object.js new file mode 100644 index 00000000..f18e9bd5 --- /dev/null +++ b/src/qtiItem/core/Object.js @@ -0,0 +1,55 @@ +import Element from 'taoQtiItem/qtiItem/core/Element'; +import _ from 'lodash'; +import rendererConfig from 'taoQtiItem/qtiItem/helper/rendererConfig'; + +var QtiObject = Element.extend({ + qtiClass: 'object', + getMediaType: function() { + var type = 'undefined'; + var mimetype = this.attr('type'); + if (mimetype) { + if (mimetype.indexOf('video') === 0) { + type = 'video'; + } else if (mimetype.indexOf('audio') === 0) { + type = 'audio'; + } else if (mimetype.indexOf('image') === 0) { + type = 'image'; + } else if (mimetype.indexOf('text/html') === 0) { + type = 'html'; + } else { + type = 'object'; + } + } + return type; + }, + render: function() { + var args = rendererConfig.getOptionsFromArguments(arguments), + renderer = args.renderer || this.getRenderer(), + defaultData = {}; + + switch (this.getMediaType()) { + case 'video': + defaultData.video = true; + break; + case 'audio': + defaultData.audio = true; + break; + case 'html': + defaultData.html = true; + break; + case 'image': + default: + defaultData.object = true; + } + + defaultData.attributes = { data: renderer.resolveUrl(this.attr('data')) }; + defaultData.body = this._alt; + + return this._super(_.merge(defaultData, args.data), args.placeholder, args.subclass, renderer); + }, + isEmpty: function() { + return !this.attr('data'); + } +}); + +export default QtiObject; diff --git a/src/qtiItem/core/PortableInfoControl.js b/src/qtiItem/core/PortableInfoControl.js new file mode 100644 index 00000000..cfdcfc07 --- /dev/null +++ b/src/qtiItem/core/PortableInfoControl.js @@ -0,0 +1,122 @@ +import _ from 'lodash'; +import Element from 'taoQtiItem/qtiItem/core/Element'; +import CustomElement from 'taoQtiItem/qtiItem/mixin/CustomElement'; +import NamespacedElement from 'taoQtiItem/qtiItem/mixin/NamespacedElement'; +import rendererConfig from 'taoQtiItem/qtiItem/helper/rendererConfig'; + +var PortableInfoControl = Element.extend({ + qtiClass: 'infoControl', + defaultNsName: 'pic', + defaultNsUri: 'http://www.imsglobal.org/xsd/portableInfoControl', + nsUriFragment: 'portableInfoControl', + defaultMarkupNsName: 'html5', + defaultMarkupNsUri: 'html5', + + init: function(serial, attributes) { + this._super(serial, attributes); + + this.typeIdentifier = ''; + this.markup = ''; + this.properties = {}; + this.libraries = []; + this.entryPoint = ''; + + //note : if the uri is defined, it will be set the uri in the xml on xml serialization, + //which may trigger xsd validation, which is troublesome for html5 (use xhtml5 maybe ?) + this.markupNs = {}; + + //stack of callback waiting to be ready + this.readyStack = []; + }, + + is: function(qtiClass) { + return qtiClass === 'infoControl' || this._super(qtiClass); + }, + + render: function() { + var args = rendererConfig.getOptionsFromArguments(arguments), + renderer = args.renderer || this.getRenderer(), + defaultData = { + typeIdentifier: this.typeIdentifier, + markup: this.markup, + properties: this.properties, + libraries: this.libraries, + entryPoint: this.entryPoint, + ns: { + pic: this.getNamespace().name + ':' + } + }; + + return this._super(_.merge(defaultData, args.data), args.placeholder, args.subclass, renderer); + }, + + /** + * Retrieve the state of the infoControl + * The call will be delegated to the infoControl's renderer. + * + * @returns {Object} the state + */ + getState: function getState() { + var ret = null; + var renderer = this.getRenderer(); + if (renderer && _.isFunction(renderer.getState)) { + ret = renderer.getState(this); + } + return ret; + }, + + /** + * Set the state of the infoControl + * The state will be set to the infoControl's renderer. + * + * @param {Object} state - the infoControl's state + */ + setState: function setState(state) { + var renderer = this.getRenderer(); + if (renderer && _.isFunction(renderer.getState)) { + renderer.setState(this, state); + } + }, + + toArray: function() { + var arr = this._super(); + arr.markup = this.markup; + arr.properties = this.properties; + return arr; + }, + + /** + * Execute a callback when the PIC is ready (ie. registered, loaded and rendererd) + * @param {Function} cb - the function to execute once ready + */ + onReady: function onReady(cb) { + this.readyStack.push(cb); + + //if we are ready this will pop the stack + if (this.data('_ready') && this.data('pic')) { + this.triggerReady(); + } + }, + + /** + * Define the PIC as ready and consume the waiting functions in the stack. + */ + triggerReady: function triggerReady() { + var self = this; + _.forEach(this.readyStack, function(cb) { + cb.call(self, self.data('pic')); + }); + + //empty the stack + this.readyStack = []; + + //mark the infoControl as ready + this.data('_ready', true); + } +}); + +//add portable element standard functions +CustomElement.augment(PortableInfoControl); +NamespacedElement.augment(PortableInfoControl); + +export default PortableInfoControl; diff --git a/src/qtiItem/core/PrintedVariable.js b/src/qtiItem/core/PrintedVariable.js new file mode 100644 index 00000000..38a85161 --- /dev/null +++ b/src/qtiItem/core/PrintedVariable.js @@ -0,0 +1,27 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2017 (original work) Open Assessment Technologies SA; + */ +/** + * @author Christophe Noël + */ +import Element from 'taoQtiItem/qtiItem/core/Element'; + +var PrintedVariable = Element.extend({ + qtiClass: 'printedVariable' +}); + +export default PrintedVariable; diff --git a/src/qtiItem/core/ResponseProcessing.js b/src/qtiItem/core/ResponseProcessing.js new file mode 100644 index 00000000..d2c70102 --- /dev/null +++ b/src/qtiItem/core/ResponseProcessing.js @@ -0,0 +1,15 @@ +import Element from 'taoQtiItem/qtiItem/core/Element'; + +var ResponseProcessing = Element.extend({ + qtiClass: 'responseProcessing', + processingType: '', + xml: '', + toArray: function() { + var arr = this._super(); + arr.processingType = this.processingType; + arr.xml = this.xml; + return arr; + } +}); + +export default ResponseProcessing; diff --git a/src/qtiItem/core/RubricBlock.js b/src/qtiItem/core/RubricBlock.js new file mode 100644 index 00000000..1ae7ff06 --- /dev/null +++ b/src/qtiItem/core/RubricBlock.js @@ -0,0 +1,13 @@ +import Element from 'taoQtiItem/qtiItem/core/Element'; +import Container from 'taoQtiItem/qtiItem/mixin/Container'; + +var RubricBlock = Element.extend({ + qtiClass: 'rubricBlock', + isEmpty: function isEmpty() { + return !(this.bdy && this.bdy.body()); + } +}); + +Container.augment(RubricBlock); + +export default RubricBlock; diff --git a/src/qtiItem/core/Stylesheet.js b/src/qtiItem/core/Stylesheet.js new file mode 100644 index 00000000..48d084b1 --- /dev/null +++ b/src/qtiItem/core/Stylesheet.js @@ -0,0 +1,18 @@ +import Element from 'taoQtiItem/qtiItem/core/Element'; +import _ from 'lodash'; +import rendererConfig from 'taoQtiItem/qtiItem/helper/rendererConfig'; + +var Stylesheet = Element.extend({ + qtiClass: 'stylesheet', + render: function() { + var args = rendererConfig.getOptionsFromArguments(arguments), + renderer = args.renderer || this.getRenderer(), + defaultData = {}; + + defaultData.attributes = { href: renderer.resolveUrl(this.attr('href')) }; + + return this._super(_.merge(defaultData, args.data), args.placeholder, args.subclass, renderer); + } +}); + +export default Stylesheet; diff --git a/src/qtiItem/core/Table.js b/src/qtiItem/core/Table.js new file mode 100644 index 00000000..e76de041 --- /dev/null +++ b/src/qtiItem/core/Table.js @@ -0,0 +1,41 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2017 (original work) Open Assessment Technologies SA; + */ +/** + * @author Christophe Noël + */ +import _ from 'lodash'; +import Element from 'taoQtiItem/qtiItem/core/Element'; +import Container from 'taoQtiItem/qtiItem/mixin/ContainerTable'; + +var Table = Element.extend({ + qtiClass: 'table' +}); + +Container.augment(Table); + +Table = Table.extend({ + // on table creation, we might get a body wrapped in a table element, that we need to get rid of + body: function(newBody) { + if (_.isString(newBody)) { + newBody = newBody.replace('', '').replace('
        ', ''); + } + return this._super(newBody); + } +}); + +export default Table; diff --git a/src/qtiItem/core/Tooltip.js b/src/qtiItem/core/Tooltip.js new file mode 100644 index 00000000..556f5624 --- /dev/null +++ b/src/qtiItem/core/Tooltip.js @@ -0,0 +1,68 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2017 (original work) Open Assessment Technologies SA; + */ +/** + * @author Christophe Noël + */ +import _ from 'lodash'; +import Element from 'taoQtiItem/qtiItem/core/Element'; +import ContainerInline from 'taoQtiItem/qtiItem/mixin/ContainerInline'; +import rendererConfig from 'taoQtiItem/qtiItem/helper/rendererConfig'; + +var Tooltip = Element.extend({ + qtiClass: '_tooltip', + + init: function(serial, attributes, newContent) { + this._super(serial, attributes); + this.content(newContent || ''); + }, + + /** + * Set/get the content of the tooltip + * @param {String} newContent - as HTML + * @returns {Element|String} + */ + content: function content(newContent) { + if (typeof newContent === 'undefined') { + return this.tooltipContent; + } else { + if (typeof newContent === 'string') { + this.tooltipContent = newContent; + } else { + throw new Error('newContent must be a string'); + } + } + return this; + }, + + /** + * Adds the tooltip content to the template data + */ + render: function() { + var args = rendererConfig.getOptionsFromArguments(arguments), + renderer = args.renderer || this.getRenderer(), + defaultData = { + content: this.tooltipContent + }; + + return this._super(_.merge(defaultData, args.data), args.placeholder, args.subclass, renderer); + } +}); + +ContainerInline.augment(Tooltip); + +export default Tooltip; diff --git a/src/qtiItem/core/choices/AssociableHotspot.js b/src/qtiItem/core/choices/AssociableHotspot.js new file mode 100644 index 00000000..4de537c9 --- /dev/null +++ b/src/qtiItem/core/choices/AssociableHotspot.js @@ -0,0 +1,5 @@ +import QtiHotspot from 'taoQtiItem/qtiItem/core/choices/Hotspot'; +var QtiAssociableHotspot = QtiHotspot.extend({ + qtiClass: 'associableHotspot' +}); +export default QtiAssociableHotspot; diff --git a/src/qtiItem/core/choices/Choice.js b/src/qtiItem/core/choices/Choice.js new file mode 100644 index 00000000..afe0c757 --- /dev/null +++ b/src/qtiItem/core/choices/Choice.js @@ -0,0 +1,24 @@ +import IdentifiedElement from 'taoQtiItem/qtiItem/core/IdentifiedElement'; + +var Choice = IdentifiedElement.extend({ + init: function(serial, attributes) { + this._super(serial, attributes); + }, + is: function(qtiClass) { + return qtiClass === 'choice' || this._super(qtiClass); + }, + getInteraction: function() { + var found, + ret = null, + item = this.getRootElement(); + if (item) { + found = item.find(this.serial); + if (found) { + ret = found.parent; + } + } + return ret; + } +}); + +export default Choice; diff --git a/src/qtiItem/core/choices/ContainerChoice.js b/src/qtiItem/core/choices/ContainerChoice.js new file mode 100644 index 00000000..a864d954 --- /dev/null +++ b/src/qtiItem/core/choices/ContainerChoice.js @@ -0,0 +1,15 @@ +import Choice from 'taoQtiItem/qtiItem/core/choices/Choice'; +import Container from 'taoQtiItem/qtiItem/mixin/Container'; + +var ContainerChoice = Choice.extend({ + init: function(serial, attributes) { + this._super(serial, attributes); + }, + is: function(qtiClass) { + return qtiClass === 'containerChoice' || this._super(qtiClass); + } +}); + +Container.augment(ContainerChoice); + +export default ContainerChoice; diff --git a/src/qtiItem/core/choices/Gap.js b/src/qtiItem/core/choices/Gap.js new file mode 100644 index 00000000..9fc13b7d --- /dev/null +++ b/src/qtiItem/core/choices/Gap.js @@ -0,0 +1,5 @@ +import QtiChoice from 'taoQtiItem/qtiItem/core/choices/Choice'; +var QtiGap = QtiChoice.extend({ + qtiClass: 'gap' +}); +export default QtiGap; diff --git a/src/qtiItem/core/choices/GapImg.js b/src/qtiItem/core/choices/GapImg.js new file mode 100644 index 00000000..a046b484 --- /dev/null +++ b/src/qtiItem/core/choices/GapImg.js @@ -0,0 +1,14 @@ +import QtiChoice from 'taoQtiItem/qtiItem/core/choices/Choice'; +import QtiObject from 'taoQtiItem/qtiItem/core/Object'; +var QtiGapImg = QtiChoice.extend({ + qtiClass: 'gapImg', + //common methods to object containers (start) + initObject: function(object) { + this.object = object || new QtiObject(); + }, + getObject: function() { + return this.object; + } + //common methods to object containers (end) +}); +export default QtiGapImg; diff --git a/src/qtiItem/core/choices/GapText.js b/src/qtiItem/core/choices/GapText.js new file mode 100644 index 00000000..7534f395 --- /dev/null +++ b/src/qtiItem/core/choices/GapText.js @@ -0,0 +1,23 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2017 (original work) Open Assessment Technologies SA; + */ +import QtiContainerChoice from 'taoQtiItem/qtiItem/core/choices/ContainerChoice'; + +var QtiGapText = QtiContainerChoice.extend({ + qtiClass: 'gapText' +}); +export default QtiGapText; diff --git a/src/qtiItem/core/choices/Hotspot.js b/src/qtiItem/core/choices/Hotspot.js new file mode 100644 index 00000000..316cf526 --- /dev/null +++ b/src/qtiItem/core/choices/Hotspot.js @@ -0,0 +1,3 @@ +import QtiChoice from 'taoQtiItem/qtiItem/core/choices/Choice'; +var QtiHotspot = QtiChoice.extend({}); +export default QtiHotspot; diff --git a/src/qtiItem/core/choices/HotspotChoice.js b/src/qtiItem/core/choices/HotspotChoice.js new file mode 100644 index 00000000..029c5625 --- /dev/null +++ b/src/qtiItem/core/choices/HotspotChoice.js @@ -0,0 +1,5 @@ +import QtiHotspot from 'taoQtiItem/qtiItem/core/choices/Hotspot'; +var QtiHotspotChoice = QtiHotspot.extend({ + qtiClass: 'hotspotChoice' +}); +export default QtiHotspotChoice; diff --git a/src/qtiItem/core/choices/Hottext.js b/src/qtiItem/core/choices/Hottext.js new file mode 100644 index 00000000..16b8a1b5 --- /dev/null +++ b/src/qtiItem/core/choices/Hottext.js @@ -0,0 +1,10 @@ +import Choice from 'taoQtiItem/qtiItem/core/choices/Choice'; +import Container from 'taoQtiItem/qtiItem/mixin/ContainerInline'; + +var Hottext = Choice.extend({ + qtiClass: 'hottext' +}); + +Container.augment(Hottext); + +export default Hottext; diff --git a/src/qtiItem/core/choices/InlineChoice.js b/src/qtiItem/core/choices/InlineChoice.js new file mode 100644 index 00000000..d0f7ec5e --- /dev/null +++ b/src/qtiItem/core/choices/InlineChoice.js @@ -0,0 +1,5 @@ +import QtiTextVariableChoice from 'taoQtiItem/qtiItem/core/choices/TextVariableChoice'; +var QtiInlineChoice = QtiTextVariableChoice.extend({ + qtiClass: 'inlineChoice' +}); +export default QtiInlineChoice; diff --git a/src/qtiItem/core/choices/SimpleAssociableChoice.js b/src/qtiItem/core/choices/SimpleAssociableChoice.js new file mode 100644 index 00000000..8a7ab28a --- /dev/null +++ b/src/qtiItem/core/choices/SimpleAssociableChoice.js @@ -0,0 +1,7 @@ +import QtiContainerChoice from 'taoQtiItem/qtiItem/core/choices/ContainerChoice'; + +var QtiSimpleAssociableChoice = QtiContainerChoice.extend({ + qtiClass: 'simpleAssociableChoice' +}); + +export default QtiSimpleAssociableChoice; diff --git a/src/qtiItem/core/choices/SimpleChoice.js b/src/qtiItem/core/choices/SimpleChoice.js new file mode 100644 index 00000000..21c5058c --- /dev/null +++ b/src/qtiItem/core/choices/SimpleChoice.js @@ -0,0 +1,7 @@ +import QtiContainerChoice from 'taoQtiItem/qtiItem/core/choices/ContainerChoice'; + +var QtiSimpleChoice = QtiContainerChoice.extend({ + qtiClass: 'simpleChoice' +}); + +export default QtiSimpleChoice; diff --git a/src/qtiItem/core/choices/TextEntry.js b/src/qtiItem/core/choices/TextEntry.js new file mode 100644 index 00000000..1fb30dba --- /dev/null +++ b/src/qtiItem/core/choices/TextEntry.js @@ -0,0 +1,5 @@ +import QtiTextVariableChoice from 'taoQtiItem/qtiItem/core/choices/TextVariableChoice'; +var QtiTextEntry = QtiTextVariableChoice.extend({ + qtiClass: 'textEntry' +}); +export default QtiTextEntry; diff --git a/src/qtiItem/core/choices/TextVariableChoice.js b/src/qtiItem/core/choices/TextVariableChoice.js new file mode 100644 index 00000000..aa6bb381 --- /dev/null +++ b/src/qtiItem/core/choices/TextVariableChoice.js @@ -0,0 +1,41 @@ +import $ from 'jquery'; +import _ from 'lodash'; +import QtiChoice from 'taoQtiItem/qtiItem/core/choices/Choice'; +import rendererConfig from 'taoQtiItem/qtiItem/helper/rendererConfig'; + +var QtiTextVariableChoice = QtiChoice.extend({ + init: function(serial, attributes, text) { + this._super(serial, attributes); + this.val(text || ''); + }, + is: function(qtiClass) { + return qtiClass === 'textVariableChoice' || this._super(qtiClass); + }, + val: function(text) { + if (typeof text === 'undefined') { + return this.text; + } else { + if (typeof text === 'string') { + this.text = text; + $(document).trigger('choiceTextChange', { + choice: this, + text: text + }); + } else { + throw 'text must be a string'; + } + } + return this; + }, + render: function() { + var args = rendererConfig.getOptionsFromArguments(arguments), + renderer = args.renderer || this.getRenderer(), + defaultData = { + body: this.text + }; + + return this._super(_.merge(defaultData, args.data), args.placeholder, args.subclass, renderer); + } +}); + +export default QtiTextVariableChoice; diff --git a/src/qtiItem/core/feedbacks/Feedback.js b/src/qtiItem/core/feedbacks/Feedback.js new file mode 100644 index 00000000..1bc5e7cc --- /dev/null +++ b/src/qtiItem/core/feedbacks/Feedback.js @@ -0,0 +1,7 @@ +import IdentifiedElement from 'taoQtiItem/qtiItem/core/IdentifiedElement'; +var Feedback = IdentifiedElement.extend({ + is: function(qtiClass) { + return qtiClass === 'feedback' || this._super(qtiClass); + } +}); +export default Feedback; diff --git a/src/qtiItem/core/feedbacks/FeedbackBlock.js b/src/qtiItem/core/feedbacks/FeedbackBlock.js new file mode 100644 index 00000000..66bdf9b8 --- /dev/null +++ b/src/qtiItem/core/feedbacks/FeedbackBlock.js @@ -0,0 +1,3 @@ +import Feedback from 'taoQtiItem/qtiItem/core/feedbacks/Feedback'; +var FeedbackBlock = Feedback.extend({}); +export default FeedbackBlock; diff --git a/src/qtiItem/core/feedbacks/FeedbackInline.js b/src/qtiItem/core/feedbacks/FeedbackInline.js new file mode 100644 index 00000000..e0491b62 --- /dev/null +++ b/src/qtiItem/core/feedbacks/FeedbackInline.js @@ -0,0 +1,3 @@ +import Feedback from 'taoQtiItem/qtiItem/core/feedbacks/Feedback'; +var FeedbackInline = Feedback.extend({}); +export default FeedbackInline; diff --git a/src/qtiItem/core/feedbacks/ModalFeedback.js b/src/qtiItem/core/feedbacks/ModalFeedback.js new file mode 100644 index 00000000..ec298fda --- /dev/null +++ b/src/qtiItem/core/feedbacks/ModalFeedback.js @@ -0,0 +1,13 @@ +import IdentifiedElement from 'taoQtiItem/qtiItem/core/IdentifiedElement'; +import Container from 'taoQtiItem/qtiItem/mixin/Container'; + +var ModalFeedback = IdentifiedElement.extend({ + qtiClass: 'modalFeedback', + is: function(qtiClass) { + return qtiClass === 'feedback' || this._super(qtiClass); + } +}); + +Container.augment(ModalFeedback); + +export default ModalFeedback; diff --git a/src/qtiItem/core/interactions/AssociateInteraction.js b/src/qtiItem/core/interactions/AssociateInteraction.js new file mode 100644 index 00000000..5488c88a --- /dev/null +++ b/src/qtiItem/core/interactions/AssociateInteraction.js @@ -0,0 +1,40 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014-2017 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + * + */ +import BlockInteraction from 'taoQtiItem/qtiItem/core/interactions/BlockInteraction'; +import maxScore from 'taoQtiItem/qtiItem/helper/maxScore'; + +var AssociateInteraction = BlockInteraction.extend({ + qtiClass: 'associateInteraction', + getNormalMaximum: function getNormalMaximum() { + var calculatePossiblePairs = function calculatePossiblePairs(associateInteraction) { + var i, + j, + pairs = []; + var choices = maxScore.getMatchMaxOrderedChoices(associateInteraction.getChoices()); + for (i = 0; i < choices.length; i++) { + for (j = i; j < choices.length; j++) { + pairs.push([choices[i].id, choices[j].id]); + } + } + return pairs; + }; + return maxScore.associateInteractionBased(this, { possiblePairs: calculatePossiblePairs(this) }); + } +}); +export default AssociateInteraction; diff --git a/src/qtiItem/core/interactions/BlockInteraction.js b/src/qtiItem/core/interactions/BlockInteraction.js new file mode 100644 index 00000000..d9ddac24 --- /dev/null +++ b/src/qtiItem/core/interactions/BlockInteraction.js @@ -0,0 +1,42 @@ +import Interaction from 'taoQtiItem/qtiItem/core/interactions/Interaction'; +import Prompt from 'taoQtiItem/qtiItem/core/interactions/Prompt'; +import _ from 'lodash'; +import rendererConfig from 'taoQtiItem/qtiItem/helper/rendererConfig'; + +var BlockInteraction = Interaction.extend({ + init: function(serial, attributes) { + this._super(serial, attributes); + this.prompt = new Prompt(''); + }, + is: function(qtiClass) { + return qtiClass === 'blockInteraction' || this._super(qtiClass); + }, + getComposingElements: function() { + var elts = this._super(); + elts = _.extend(elts, this.prompt.getComposingElements()); + elts[this.prompt.getSerial()] = this.prompt; + return elts; + }, + find: function(serial) { + return this._super(serial) || this.prompt.find(serial); + }, + render: function() { + var args = rendererConfig.getOptionsFromArguments(arguments), + renderer = args.renderer || this.getRenderer(), + defaultData = { + prompt: this.prompt.render(renderer) + }; + + return this._super(_.merge(defaultData, args.data), args.placeholder, args.subclass, renderer); + }, + postRender: function(data, altClassName, renderer) { + renderer = renderer || this.getRenderer(); + return [].concat(this.prompt.postRender({}, '', renderer)).concat(this._super(data, altClassName, renderer)); + }, + toArray: function() { + var arr = this._super(); + arr.prompt = this.prompt.toArray(); + return arr; + } +}); +export default BlockInteraction; diff --git a/src/qtiItem/core/interactions/ChoiceInteraction.js b/src/qtiItem/core/interactions/ChoiceInteraction.js new file mode 100644 index 00000000..b611de82 --- /dev/null +++ b/src/qtiItem/core/interactions/ChoiceInteraction.js @@ -0,0 +1,28 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014-2017 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + * + */ +import BlockInteraction from 'taoQtiItem/qtiItem/core/interactions/BlockInteraction'; +import maxScore from 'taoQtiItem/qtiItem/helper/maxScore'; + +var ChoiceInteraction = BlockInteraction.extend({ + qtiClass: 'choiceInteraction', + getNormalMaximum: function getNormalMaximum() { + return maxScore.choiceInteractionBased(this); + } +}); +export default ChoiceInteraction; diff --git a/src/qtiItem/core/interactions/ContainerInteraction.js b/src/qtiItem/core/interactions/ContainerInteraction.js new file mode 100644 index 00000000..d623738f --- /dev/null +++ b/src/qtiItem/core/interactions/ContainerInteraction.js @@ -0,0 +1,5 @@ +import BlockInteraction from 'taoQtiItem/qtiItem/core/interactions/BlockInteraction'; +import Container from 'taoQtiItem/qtiItem/mixin/Container'; +var ContainerInteraction = BlockInteraction.extend({}); +Container.augment(ContainerInteraction); +export default ContainerInteraction; diff --git a/src/qtiItem/core/interactions/CustomInteraction.js b/src/qtiItem/core/interactions/CustomInteraction.js new file mode 100644 index 00000000..aed7aa2f --- /dev/null +++ b/src/qtiItem/core/interactions/CustomInteraction.js @@ -0,0 +1,139 @@ +import _ from 'lodash'; +import Interaction from 'taoQtiItem/qtiItem/core/interactions/Interaction'; +import CustomElement from 'taoQtiItem/qtiItem/mixin/CustomElement'; +import NamespacedElement from 'taoQtiItem/qtiItem/mixin/NamespacedElement'; +import rendererConfig from 'taoQtiItem/qtiItem/helper/rendererConfig'; + +var CustomInteraction = Interaction.extend({ + qtiClass: 'customInteraction', + defaultNsName: 'pci', + defaultNsUri: 'http://www.imsglobal.org/xsd/portableCustomInteraction', + nsUriFragment: 'portableCustomInteraction', + defaultMarkupNsName: 'html5', + defaultMarkupNsUri: 'html5', + init: function(serial, attributes) { + this._super(serial, attributes); + + this.typeIdentifier = ''; + this.markup = ''; + this.properties = {}; + this.libraries = []; + this.entryPoint = ''; + + //note : if the uri is defined, it will be set the uri in the xml on xml serialization, + //which may trigger xsd validation, which is troublesome for html5 (use xhtml5 maybe ?) + this.markupNs = {}; + + this.pciReadyCallbacks = []; + }, + is: function(qtiClass) { + return qtiClass === 'customInteraction' || this._super(qtiClass); + }, + render: function() { + var args = rendererConfig.getOptionsFromArguments(arguments), + renderer = args.renderer || this.getRenderer(), + defaultData = { + typeIdentifier: this.typeIdentifier, + markup: this.markup, + properties: this.properties, + libraries: this.libraries, + entryPoint: this.entryPoint, + ns: { + pci: this.getNamespace().name + ':' + } + }; + + return this._super(_.merge(defaultData, args.data), args.placeholder, args.subclass, renderer); + }, + toArray: function() { + var arr = this._super(); + arr.markup = this.markup; + arr.properties = this.properties; + return arr; + }, + getMarkupNamespace: function() { + if (this.markupNs && this.markupNs.name && this.markupNs.uri) { + return _.clone(this.markupNs); + } else { + var relatedItem = this.getRootElement(); + if (relatedItem) { + //set the default one: + relatedItem.namespaces[this.defaultMarkupNsName] = this.defaultMarkupNsUri; + return { + name: this.defaultMarkupNsName, + uri: this.defaultMarkupNsUri + }; + } + } + + return {}; + }, + setMarkupNamespace: function(name, uri) { + this.markupNs = { + name: name, + uri: uri + }; + }, + onPciReady: function(callback) { + this.pciReadyCallbacks.push(callback); + + if (this.data('pci')) { + //if pci is already ready, call it immediately + this.triggerPciReady(); + } + }, + triggerPciReady: function() { + var _this = this, + pci = this.data('pci'); + + if (pci) { + _.each(this.pciReadyCallbacks, function(fn) { + fn.call(_this, pci); + }); + + //empty the stack of ready callbacks + this.pciReadyCallbacks = []; + + //mark the interaction as ready + this.data('pciReady', true); + } else { + throw 'cannot trigger pci ready when no pci is actually attached to the interaction'; + } + }, + onPci: function(event, callback) { + this.onPciReady(function(pci) { + if (_.isFunction(pci.on)) { + pci.on(event, callback); + } else { + throw 'the pci does not implement on() function'; + } + }); + return this; + }, + offPci: function(event) { + this.onPciReady(function(pci) { + if (_.isFunction(pci.off)) { + pci.off(event); + } else { + throw 'the pci does not implement off() function'; + } + }); + return this; + }, + triggerPci: function(event, args) { + this.onPciReady(function(pci) { + if (_.isFunction(pci.off)) { + pci.trigger(event, args); + } else { + throw 'the pci does not implement off() function'; + } + }); + return this; + } +}); + +//add portable element standard functions +CustomElement.augment(CustomInteraction); +NamespacedElement.augment(CustomInteraction); + +export default CustomInteraction; diff --git a/src/qtiItem/core/interactions/EndAttemptInteraction.js b/src/qtiItem/core/interactions/EndAttemptInteraction.js new file mode 100644 index 00000000..df6fd44a --- /dev/null +++ b/src/qtiItem/core/interactions/EndAttemptInteraction.js @@ -0,0 +1,4 @@ +import InlineInteraction from 'taoQtiItem/qtiItem/core/interactions/InlineInteraction'; +export default InlineInteraction.extend({ + qtiClass: 'endAttemptInteraction' +}); diff --git a/src/qtiItem/core/interactions/ExtendedTextInteraction.js b/src/qtiItem/core/interactions/ExtendedTextInteraction.js new file mode 100644 index 00000000..58e9cc0c --- /dev/null +++ b/src/qtiItem/core/interactions/ExtendedTextInteraction.js @@ -0,0 +1,53 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014-2017 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + * + */ +import _ from 'lodash'; +import BlockInteraction from 'taoQtiItem/qtiItem/core/interactions/BlockInteraction'; +import rendererConfig from 'taoQtiItem/qtiItem/helper/rendererConfig'; +import maxScore from 'taoQtiItem/qtiItem/helper/maxScore'; + +var ExtendedTextInteraction = BlockInteraction.extend({ + qtiClass: 'extendedTextInteraction', + render: function render() { + var i, + args = rendererConfig.getOptionsFromArguments(arguments), + renderer = args.renderer || this.getRenderer(), + defaultData = { + multiple: false, + maxStringLoop: [] + }, + response = this.getResponseDeclaration(); + + if ( + this.attr('maxStrings') && + (response.attr('cardinality') === 'multiple' || response.attr('cardinality') === 'ordered') + ) { + defaultData.multiple = true; + for (i = 0; i < this.attr('maxStrings'); i++) { + defaultData.maxStringLoop.push(i + ''); //need to convert to string. The tpl engine fails otherwise + } + } + + return this._super(_.merge(defaultData, args.data), args.placeholder, args.subclass, renderer); + }, + getNormalMaximum: function getNormalMaximum() { + return maxScore.textEntryInteractionBased(this); + } +}); + +export default ExtendedTextInteraction; diff --git a/src/qtiItem/core/interactions/GapMatchInteraction.js b/src/qtiItem/core/interactions/GapMatchInteraction.js new file mode 100644 index 00000000..634d4c77 --- /dev/null +++ b/src/qtiItem/core/interactions/GapMatchInteraction.js @@ -0,0 +1,31 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014-2017 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + * + */ +import ContainerInteraction from 'taoQtiItem/qtiItem/core/interactions/ContainerInteraction'; +import maxScore from 'taoQtiItem/qtiItem/helper/maxScore'; + +var GapMatchInteraction = ContainerInteraction.extend({ + qtiClass: 'gapMatchInteraction', + getGaps: function getGaps() { + return this.getBody().getElements('gap'); + }, + getNormalMaximum: function getNormalMaximum() { + return maxScore.gapMatchInteractionBased(this); + } +}); +export default GapMatchInteraction; diff --git a/src/qtiItem/core/interactions/GraphicAssociateInteraction.js b/src/qtiItem/core/interactions/GraphicAssociateInteraction.js new file mode 100644 index 00000000..7883be92 --- /dev/null +++ b/src/qtiItem/core/interactions/GraphicAssociateInteraction.js @@ -0,0 +1,42 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014-2017 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + * + */ +import GraphicInteraction from 'taoQtiItem/qtiItem/core/interactions/GraphicInteraction'; +import maxScore from 'taoQtiItem/qtiItem/helper/maxScore'; + +var GraphicAssociateInteraction = GraphicInteraction.extend({ + qtiClass: 'graphicAssociateInteraction', + getNormalMaximum: function getNormalMaximum() { + var calculatePossiblePairs = function calculatePossiblePairs(associateInteraction) { + var i, + j, + pairs = []; + var choices = maxScore.getMatchMaxOrderedChoices(associateInteraction.getChoices()); + for (i = 0; i < choices.length; i++) { + for (j = i; j < choices.length; j++) { + if (i !== j) { + pairs.push([choices[i].id, choices[j].id]); + } + } + } + return pairs; + }; + return maxScore.associateInteractionBased(this, { possiblePairs: calculatePossiblePairs(this) }); + } +}); +export default GraphicAssociateInteraction; diff --git a/src/qtiItem/core/interactions/GraphicGapMatchInteraction.js b/src/qtiItem/core/interactions/GraphicGapMatchInteraction.js new file mode 100644 index 00000000..fdc2ed05 --- /dev/null +++ b/src/qtiItem/core/interactions/GraphicGapMatchInteraction.js @@ -0,0 +1,132 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014-2017 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + * + */ +import GraphicInteraction from 'taoQtiItem/qtiItem/core/interactions/GraphicInteraction'; +import Element from 'taoQtiItem/qtiItem/core/Element'; +import _ from 'lodash'; +import rendererConfig from 'taoQtiItem/qtiItem/helper/rendererConfig'; +import maxScore from 'taoQtiItem/qtiItem/helper/maxScore'; + +var GraphicGapMatchInteraction = GraphicInteraction.extend({ + qtiClass: 'graphicGapMatchInteraction', + init: function init(serial, attributes) { + this._super(serial, attributes); + this.gapImgs = {}; + }, + addGapImg: function addGapImg(gapImg) { + if (Element.isA(gapImg, 'gapImg')) { + gapImg.setRootElement(this.getRootElement() || null); + this.gapImgs[gapImg.getSerial()] = gapImg; + } + }, + removeGapImg: function removeGapImg(gapImg) { + var serial = ''; + if (typeof gapImg === 'string') { + serial = gapImg; + } else if (Element.isA(gapImg, 'gapImg')) { + serial = gapImg.getSerial(); + } + delete this.gapImgs[serial]; + return this; + }, + getGapImgs: function getGapImgs() { + return _.clone(this.gapImgs); + }, + getGapImg: function getGapImg(serial) { + return this.gapImgs[serial]; + }, + getChoiceByIdentifier: function getChoiceByIdentifier(identifier) { + var choice = this._super(identifier); + if (!choice) { + //if not found among the choices, search the gapImgs + choice = _.find(this.gapImgs, function(elt) { + return elt && elt.id() === identifier; + }); + } + return choice; + }, + getComposingElements: function getComposingElements() { + var serial, + elts = this._super(); + //recursive to choices: + for (serial in this.gapImgs) { + elts[serial] = this.gapImgs[serial]; + elts = _.extend(elts, this.gapImgs[serial].getComposingElements()); + } + return elts; + }, + find: function find(serial) { + var found = this._super(serial); + if (!found) { + if (this.gapImgs[serial]) { + found = { parent: this, element: this.gapImgs[serial] }; + } + } + return found; + }, + render: function render() { + var serial, + args = rendererConfig.getOptionsFromArguments(arguments), + renderer = args.renderer || this.getRenderer(), + defaultData = { + gapImgs: [] + }; + + //note: no choice shuffling option available for graphic gap match + var gapImgs = this.getGapImgs(); + for (serial in gapImgs) { + if (Element.isA(gapImgs[serial], 'choice')) { + defaultData.gapImgs.push(gapImgs[serial].render({}, null, '', renderer)); + } + } + + return this._super(_.merge(defaultData, args.data), args.placeholder, args.subclass, renderer); + }, + toArray: function toArray() { + var serial, + gapImgs, + arr = this._super(); + arr.gapImgs = {}; + gapImgs = this.getGapImgs(); + for (serial in gapImgs) { + arr.gapImgs[serial] = gapImgs[serial].toArray(); + } + return arr; + }, + getNormalMaximum: function getNormalMaximum() { + var calculatePossiblePairs = function calculatePossiblePairs(graphicGapInteraction) { + var pairs = []; + var matchSet1 = maxScore.getMatchMaxOrderedChoices(graphicGapInteraction.getGapImgs()); + var matchSet2 = maxScore.getMatchMaxOrderedChoices(graphicGapInteraction.getChoices()); + + _.forEach(matchSet1, function(choice1) { + _.forEach(matchSet2, function(choice2) { + pairs.push([choice1.id, choice2.id]); + }); + }); + + return pairs; + }; + return maxScore.associateInteractionBased(this, { + possiblePairs: calculatePossiblePairs(this), + checkInfinitePair: true + }); + } +}); + +export default GraphicGapMatchInteraction; diff --git a/src/qtiItem/core/interactions/GraphicInteraction.js b/src/qtiItem/core/interactions/GraphicInteraction.js new file mode 100644 index 00000000..11cca690 --- /dev/null +++ b/src/qtiItem/core/interactions/GraphicInteraction.js @@ -0,0 +1,17 @@ +import QtiObjectInteraction from 'taoQtiItem/qtiItem/core/interactions/ObjectInteraction'; +import _ from 'lodash'; +import rendererConfig from 'taoQtiItem/qtiItem/helper/rendererConfig'; +var QtiGraphicInteraction = QtiObjectInteraction.extend({ + render: function() { + var args = rendererConfig.getOptionsFromArguments(arguments), + renderer = args.renderer || this.getRenderer(), + defaultData = { + backgroundImage: this.object.getAttributes(), + object: this.object.render(renderer) + }; + + return this._super(_.merge(defaultData, args.data), args.placeholder, args.subclass, renderer); + } +}); + +export default QtiGraphicInteraction; diff --git a/src/qtiItem/core/interactions/GraphicOrderInteraction.js b/src/qtiItem/core/interactions/GraphicOrderInteraction.js new file mode 100644 index 00000000..6fb63a06 --- /dev/null +++ b/src/qtiItem/core/interactions/GraphicOrderInteraction.js @@ -0,0 +1,28 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014-2017 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + * + */ +import GraphicInteraction from 'taoQtiItem/qtiItem/core/interactions/GraphicInteraction'; +import maxScore from 'taoQtiItem/qtiItem/helper/maxScore'; + +var GraphicOrderInteraction = GraphicInteraction.extend({ + qtiClass: 'graphicOrderInteraction', + getNormalMaximum: function getNormalMaximum() { + return maxScore.orderInteractionBased(this); + } +}); +export default GraphicOrderInteraction; diff --git a/src/qtiItem/core/interactions/HotspotInteraction.js b/src/qtiItem/core/interactions/HotspotInteraction.js new file mode 100644 index 00000000..8a12541a --- /dev/null +++ b/src/qtiItem/core/interactions/HotspotInteraction.js @@ -0,0 +1,28 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014-2017 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + * + */ +import GraphicInteraction from 'taoQtiItem/qtiItem/core/interactions/GraphicInteraction'; +import maxScore from 'taoQtiItem/qtiItem/helper/maxScore'; + +var HotspotInteraction = GraphicInteraction.extend({ + qtiClass: 'hotspotInteraction', + getNormalMaximum: function getNormalMaximum() { + return maxScore.choiceInteractionBased(this); + } +}); +export default HotspotInteraction; diff --git a/src/qtiItem/core/interactions/HottextInteraction.js b/src/qtiItem/core/interactions/HottextInteraction.js new file mode 100644 index 00000000..6d781851 --- /dev/null +++ b/src/qtiItem/core/interactions/HottextInteraction.js @@ -0,0 +1,36 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014-2017 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + * + */ +import ContainerInteraction from 'taoQtiItem/qtiItem/core/interactions/ContainerInteraction'; +import Element from 'taoQtiItem/qtiItem/core/Element'; +import maxScore from 'taoQtiItem/qtiItem/helper/maxScore'; + +var HottextInteraction = ContainerInteraction.extend({ + qtiClass: 'hottextInteraction', + getChoices: function() { + return this.getBody().getElements('hottext'); + }, + getChoice: function(serial) { + var element = this.getBody().getElement(serial); + return Element.isA(element, 'choice') ? element : null; + }, + getNormalMaximum: function getNormalMaximum() { + return maxScore.choiceInteractionBased(this); + } +}); +export default HottextInteraction; diff --git a/src/qtiItem/core/interactions/InlineChoiceInteraction.js b/src/qtiItem/core/interactions/InlineChoiceInteraction.js new file mode 100644 index 00000000..907e1923 --- /dev/null +++ b/src/qtiItem/core/interactions/InlineChoiceInteraction.js @@ -0,0 +1,28 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014-2017 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + * + */ +import InlineInteraction from 'taoQtiItem/qtiItem/core/interactions/InlineInteraction'; +import maxScore from 'taoQtiItem/qtiItem/helper/maxScore'; + +var InlineChoiceInteraction = InlineInteraction.extend({ + qtiClass: 'inlineChoiceInteraction', + getNormalMaximum: function getNormalMaximum() { + return maxScore.choiceInteractionBased(this, { maxChoices: 1 }); + } +}); +export default InlineChoiceInteraction; diff --git a/src/qtiItem/core/interactions/InlineInteraction.js b/src/qtiItem/core/interactions/InlineInteraction.js new file mode 100644 index 00000000..f0e55ead --- /dev/null +++ b/src/qtiItem/core/interactions/InlineInteraction.js @@ -0,0 +1,25 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2015 (original work) Open Assessment Technologies SA + * + */ +import Interaction from 'taoQtiItem/qtiItem/core/interactions/Interaction'; + +export default Interaction.extend({ + is: function(qtiClass) { + return qtiClass === 'inlineInteraction' || this._super(qtiClass); + } +}); diff --git a/src/qtiItem/core/interactions/Interaction.js b/src/qtiItem/core/interactions/Interaction.js new file mode 100644 index 00000000..c3919bad --- /dev/null +++ b/src/qtiItem/core/interactions/Interaction.js @@ -0,0 +1,231 @@ +import Element from 'taoQtiItem/qtiItem/core/Element'; +import _ from 'lodash'; +import rendererConfig from 'taoQtiItem/qtiItem/helper/rendererConfig'; +import util from 'taoQtiItem/qtiItem/helper/util'; + +var QtiInteraction = Element.extend({ + init: function(serial, attributes) { + this._super(serial, attributes); + this.choices = {}; + }, + is: function(qtiClass) { + return qtiClass === 'interaction' || this._super(qtiClass); + }, + addChoice: function(choice) { + choice.setRootElement(this.getRootElement() || null); + this.choices[choice.getSerial()] = choice; + return this; + }, + getChoices: function() { + var choices = {}; + for (var i in this.choices) { + //prevent passing the whole array by ref + choices[i] = this.choices[i]; + } + return choices; + }, + getChoice: function(serial) { + var ret = null; + if (this.choices[serial]) { + ret = this.choices[serial]; + } + return ret; + }, + getChoiceByIdentifier: function(identifier) { + for (var i in this.choices) { + if (this.choices[i].id() === identifier) { + return this.choices[i]; + } + } + return null; + }, + getComposingElements: function() { + var elts = this._super(); + //recursive to choices: + for (var serial in this.choices) { + if (Element.isA(this.choices[serial], 'choice')) { + elts[serial] = this.choices[serial]; + elts = _.extend(elts, this.choices[serial].getComposingElements()); + } + } + return elts; + }, + find: function(serial) { + var found = this._super(serial); + if (!found) { + found = util.findInCollection(this, 'choices', serial); + } + return found; + }, + getResponseDeclaration: function() { + var response = null; + var responseId = this.attr('responseIdentifier'); + if (responseId) { + var item = this.getRootElement(); + if (item) { + response = item.getResponseDeclaration(responseId); + } else { + throw 'cannot get response of an interaction out of its item context'; + } + } + return response; + }, + /** + * Render the interaction to the view. + * The optional argument "subClass" allows distinguishing customInteraction: e.g. customInteraction.matrix, customInteraction.likertScale ... + */ + render: function() { + var args = rendererConfig.getOptionsFromArguments(arguments), + renderer = args.renderer || this.getRenderer(), + defaultData = { + _type: this.qtiClass.replace(/([A-Z])/g, function($1) { + return '_' + $1.toLowerCase(); + }), + choices: [], + choiceShuffle: true + }; + + if (!renderer) { + throw 'no renderer found for the interaction ' + this.qtiClass; + } + + var choices = + this.attr('shuffle') && renderer.getOption('shuffleChoices') + ? renderer.getShuffledChoices(this) + : this.getChoices(); + var interactionData = { interaction: { serial: this.serial, attributes: this.attributes } }; + var _this = this; + _.each(choices, function(choice) { + if (Element.isA(choice, 'choice')) { + try { + var renderedChoice = choice.render( + _.clone(interactionData, true), + null, + choice.qtiClass + '.' + _this.qtiClass, + renderer + ); //use interaction type as choice subclass + defaultData.choices.push(renderedChoice); + } catch (e) { + //leave choices empty in case of error + } + } + }); + + var tplName = args.subclass ? this.qtiClass + '.' + args.subclass : this.qtiClass; + + return this._super(_.merge(defaultData, args.data), args.placeholder, tplName, renderer); + }, + postRender: function(data, altClassName, renderer) { + var self = this; + renderer = renderer || this.getRenderer(); + + return _(this.getChoices()) + .filter(function(elt) { + return Element.isA(elt, 'choice'); + }) + .map(function(choice) { + return choice.postRender({}, choice.qtiClass + '.' + self.qtiClass, renderer); + }) + .value() + .concat(this._super(data, altClassName, renderer)); + }, + setResponse: function(values) { + var ret = null; + var renderer = this.getRenderer(); + if (renderer) { + ret = renderer.setResponse(this, values); + } else { + throw 'no renderer found for the interaction ' + this.qtiClass; + } + return ret; + }, + getResponse: function() { + var ret = null; + var renderer = this.getRenderer(); + if (renderer) { + ret = renderer.getResponse(this); + } else { + throw 'no renderer found for the interaction ' + this.qtiClass; + } + return ret; + }, + resetResponse: function() { + var ret = null; + var renderer = this.getRenderer(); + if (renderer) { + ret = renderer.resetResponse(this); + } else { + throw 'no renderer found for the interaction ' + this.qtiClass; + } + return ret; + }, + + /** + * Retrieve the state of the interaction. + * The state is provided by the interaction's renderer. + * + * @returns {Object} the interaction's state + * @throws {Error} if no renderer is found + */ + getState: function() { + var ret = null; + var renderer = this.getRenderer(); + if (renderer) { + if (_.isFunction(renderer.getState)) { + ret = renderer.getState(this); + } + } else { + throw 'no renderer found for the interaction ' + this.qtiClass; + } + return ret; + }, + + /** + * Retrieve the state of the interaction. + * The state will be given to the interaction's renderer. + * + * @param {Object} state - the interaction's state + * @throws {Error} if no renderer is found + */ + setState: function(state) { + var renderer = this.getRenderer(); + if (renderer) { + if (_.isFunction(renderer.setState)) { + renderer.setState(this, state); + } + } else { + throw 'no renderer found for the interaction ' + this.qtiClass; + } + }, + + /** + * Clean up an interaction rendering. + * Ask the renderer to run destroy if exists. + * + * @throws {Error} if no renderer is found + * @returns {Promise?} the interaction destroy step can be async and can return an optional Promise + */ + clear: function() { + var renderer = this.getRenderer(); + if (renderer && _.isFunction(renderer.destroy)) { + return renderer.destroy(this); + } + }, + + toArray: function() { + var arr = this._super(); + arr.choices = {}; + for (var serial in this.choices) { + if (Element.isA(this.choices[serial], 'choice')) { + arr.choices[serial] = this.choices[serial].toArray(); + } + } + return arr; + }, + + getNormalMaximum: function getNormalMaximum() { + //by default + return false; + } +}); +export default QtiInteraction; diff --git a/src/qtiItem/core/interactions/MatchInteraction.js b/src/qtiItem/core/interactions/MatchInteraction.js new file mode 100644 index 00000000..c1235081 --- /dev/null +++ b/src/qtiItem/core/interactions/MatchInteraction.js @@ -0,0 +1,173 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014-2017 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + * + */ +import BlockInteraction from 'taoQtiItem/qtiItem/core/interactions/BlockInteraction'; +import SimpleAssociableChoice from 'taoQtiItem/qtiItem/core/choices/SimpleAssociableChoice'; +import _ from 'lodash'; +import rendererConfig from 'taoQtiItem/qtiItem/helper/rendererConfig'; +import util from 'taoQtiItem/qtiItem/helper/util'; +import maxScore from 'taoQtiItem/qtiItem/helper/maxScore'; + +var MatchInteraction = BlockInteraction.extend({ + qtiClass: 'matchInteraction', + init: function init(serial, attributes) { + this._super(serial, attributes); + this.choices = [{}, {}]; + }, + addChoice: function addChoice(choice, matchSet) { + matchSet = parseInt(matchSet); + if (this.choices[matchSet]) { + choice.setRootElement(this.getRootElement() || null); + this.choices[matchSet][choice.getSerial()] = choice; + } + }, + getChoices: function getChoices(matchSet) { + matchSet = parseInt(matchSet); + if (this.choices[matchSet]) { + return _.clone(this.choices[matchSet]); + } else { + return _.clone(this.choices); + } + }, + getChoice: function getChoice(serial) { + return this.choices[0][serial] || this.choices[1][serial] || null; + }, + getChoiceByIdentifier: function getChoiceByIdentifier(identifier) { + var i, matchSet, serial; + //recursive to both match sets: + for (i = 0; i < 2; i++) { + matchSet = this.getChoices(i); + for (serial in matchSet) { + if (matchSet[serial] instanceof SimpleAssociableChoice && matchSet[serial].id() === identifier) { + return matchSet[serial]; + } + } + } + return null; + }, + getComposingElements: function getComposingElements() { + var i, matchSet, serial; + var elts = this._super(); + //recursive to both match sets: + for (i = 0; i < 2; i++) { + matchSet = this.getChoices(i); + for (serial in matchSet) { + if (matchSet[serial] instanceof SimpleAssociableChoice) { + elts[serial] = matchSet[serial]; + elts = _.extend(elts, matchSet[serial].getComposingElements()); + } + } + } + + return elts; + }, + find: function find(serial) { + var found = this._super(serial); + if (!found) { + found = util.findInCollection(this, ['choices.0', 'choices.1'], serial); + } + return found; + }, + render: function render() { + var args = rendererConfig.getOptionsFromArguments(arguments); + var renderer = args.renderer || this.getRenderer(); + var defaultData = { + matchSet1: [], + matchSet2: [] + }; + var choices, i, matchSet, serial; + var interactionData = { interaction: { serial: this.serial, attributes: this.attributes } }; + + if (!renderer) { + throw 'no renderer found for the interaction ' + this.qtiClass; + } + + if (this.attr('shuffle') && renderer.getOption('shuffleChoices')) { + choices = renderer.getShuffledChoices(this); + } else { + choices = this.getChoices(); + } + + for (i = 0; i < 2; i++) { + matchSet = choices[i]; + for (serial in matchSet) { + if (matchSet[serial] instanceof SimpleAssociableChoice) { + defaultData['matchSet' + (i + 1)].push( + matchSet[serial].render( + _.clone(interactionData, true), + null, + 'simpleAssociableChoice.matchInteraction', + renderer + ) + ); + } + } + } + return this._super(_.merge(defaultData, args.data), args.placeholder, args.subclass, renderer); + }, + postRender: function postRender(data, altClassName, renderer) { + renderer = renderer || this.getRenderer(); + return _(this.getChoices()) + .map(function(choices) { + return _(choices) + .filter(function(choice) { + return choice instanceof SimpleAssociableChoice; + }) + .map(function(choice) { + return choice.postRender({}, 'simpleAssociableChoice.matchInteraction', renderer); + }) + .value(); + }) + .flatten(true) + .value() + .concat(this._super(data, altClassName, renderer)); + }, + toArray: function toArray() { + var i, matchSet, serial; + var arr = this._super(); + arr.choices = { 0: {}, 1: {} }; + for (i = 0; i < 2; i++) { + matchSet = this.getChoices(i); + for (serial in matchSet) { + if (matchSet[serial] instanceof SimpleAssociableChoice) { + arr.choices[i][serial] = matchSet[serial].toArray(); + } + } + } + return arr; + }, + getNormalMaximum: function getNormalMaximum() { + var calculatePossiblePairs = function calculatePossiblePairs(matchInteraction) { + //get max number of pairs + var pairs = []; + var matchSet1 = maxScore.getMatchMaxOrderedChoices(matchInteraction.getChoices(0)); + var matchSet2 = maxScore.getMatchMaxOrderedChoices(matchInteraction.getChoices(1)); + + _.forEach(matchSet1, function(choice1) { + _.forEach(matchSet2, function(choice2) { + pairs.push([choice1.id, choice2.id]); + }); + }); + + return pairs; + }; + return maxScore.associateInteractionBased(this, { possiblePairs: calculatePossiblePairs(this) }); + } +}); + +export default MatchInteraction; diff --git a/src/qtiItem/core/interactions/MediaInteraction.js b/src/qtiItem/core/interactions/MediaInteraction.js new file mode 100644 index 00000000..7f314c0d --- /dev/null +++ b/src/qtiItem/core/interactions/MediaInteraction.js @@ -0,0 +1,34 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014-2017 (original work) Open Assessment Technologies SA ; + */ +import _ from 'lodash'; +import ObjectInteraction from 'taoQtiItem/qtiItem/core/interactions/ObjectInteraction'; +import rendererConfig from 'taoQtiItem/qtiItem/helper/rendererConfig'; + +var MediaInteraction = ObjectInteraction.extend({ + qtiClass: 'mediaInteraction', + render: function render() { + var args = rendererConfig.getOptionsFromArguments(arguments), + renderer = args.renderer || this.getRenderer(), + defaultData = { + object: this.object.render({}, null, '', renderer) + }; + + return this._super(_.merge(defaultData, args.data), args.placeholder, args.subclass, renderer); + } +}); +export default MediaInteraction; diff --git a/src/qtiItem/core/interactions/ObjectInteraction.js b/src/qtiItem/core/interactions/ObjectInteraction.js new file mode 100644 index 00000000..9bc45c89 --- /dev/null +++ b/src/qtiItem/core/interactions/ObjectInteraction.js @@ -0,0 +1,14 @@ +import QtiBlockInteraction from 'taoQtiItem/qtiItem/core/interactions/BlockInteraction'; +import QtiObject from 'taoQtiItem/qtiItem/core/Object'; +var QtiObjectInteraction = QtiBlockInteraction.extend({ + //common methods to object containers (start) + initObject: function(object) { + this.object = object || new QtiObject(); + }, + getObject: function() { + return this.object; + } + //common methods to object containers (end) +}); + +export default QtiObjectInteraction; diff --git a/src/qtiItem/core/interactions/OrderInteraction.js b/src/qtiItem/core/interactions/OrderInteraction.js new file mode 100644 index 00000000..ba4d17e3 --- /dev/null +++ b/src/qtiItem/core/interactions/OrderInteraction.js @@ -0,0 +1,28 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014-2017 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + * + */ +import BlockInteraction from 'taoQtiItem/qtiItem/core/interactions/BlockInteraction'; +import maxScore from 'taoQtiItem/qtiItem/helper/maxScore'; + +var OrderInteraction = BlockInteraction.extend({ + qtiClass: 'orderInteraction', + getNormalMaximum: function getNormalMaximum() { + return maxScore.orderInteractionBased(this); + } +}); +export default OrderInteraction; diff --git a/src/qtiItem/core/interactions/Prompt.js b/src/qtiItem/core/interactions/Prompt.js new file mode 100644 index 00000000..0c9b749c --- /dev/null +++ b/src/qtiItem/core/interactions/Prompt.js @@ -0,0 +1,5 @@ +import Element from 'taoQtiItem/qtiItem/core/Element'; +import Container from 'taoQtiItem/qtiItem/mixin/ContainerInline'; +var Prompt = Element.extend({ qtiClass: 'prompt' }); +Container.augment(Prompt); +export default Prompt; diff --git a/src/qtiItem/core/interactions/SelectPointInteraction.js b/src/qtiItem/core/interactions/SelectPointInteraction.js new file mode 100644 index 00000000..e138083c --- /dev/null +++ b/src/qtiItem/core/interactions/SelectPointInteraction.js @@ -0,0 +1,28 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014-2017 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + * + */ +import GraphicInteraction from 'taoQtiItem/qtiItem/core/interactions/GraphicInteraction'; +import maxScore from 'taoQtiItem/qtiItem/helper/maxScore'; + +var SelectPointInteraction = GraphicInteraction.extend({ + qtiClass: 'selectPointInteraction', + getNormalMaximum: function getNormalMaximum() { + return maxScore.selectPointInteractionBased(this); + } +}); +export default SelectPointInteraction; diff --git a/src/qtiItem/core/interactions/SliderInteraction.js b/src/qtiItem/core/interactions/SliderInteraction.js new file mode 100644 index 00000000..d7cc3891 --- /dev/null +++ b/src/qtiItem/core/interactions/SliderInteraction.js @@ -0,0 +1,28 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014-2017 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + * + */ +import BlockInteraction from 'taoQtiItem/qtiItem/core/interactions/BlockInteraction'; +import maxScore from 'taoQtiItem/qtiItem/helper/maxScore'; + +var SliderInteraction = BlockInteraction.extend({ + qtiClass: 'sliderInteraction', + getNormalMaximum: function getNormalMaximum() { + return maxScore.sliderInteractionBased(this); + } +}); +export default SliderInteraction; diff --git a/src/qtiItem/core/interactions/TextEntryInteraction.js b/src/qtiItem/core/interactions/TextEntryInteraction.js new file mode 100644 index 00000000..eb886e15 --- /dev/null +++ b/src/qtiItem/core/interactions/TextEntryInteraction.js @@ -0,0 +1,28 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014-2017 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + * + */ +import InlineInteraction from 'taoQtiItem/qtiItem/core/interactions/InlineInteraction'; +import maxScore from 'taoQtiItem/qtiItem/helper/maxScore'; + +var TextEntryInteraction = InlineInteraction.extend({ + qtiClass: 'textEntryInteraction', + getNormalMaximum: function getNormalMaximum() { + return maxScore.textEntryInteractionBased(this); + } +}); +export default TextEntryInteraction; diff --git a/src/qtiItem/core/interactions/UploadInteraction.js b/src/qtiItem/core/interactions/UploadInteraction.js new file mode 100644 index 00000000..65c364be --- /dev/null +++ b/src/qtiItem/core/interactions/UploadInteraction.js @@ -0,0 +1,5 @@ +import InlineInteraction from 'taoQtiItem/qtiItem/core/interactions/BlockInteraction'; +var UploadInteraction = InlineInteraction.extend({ + qtiClass: 'uploadInteraction' +}); +export default UploadInteraction; diff --git a/src/qtiItem/core/qtiClasses.js b/src/qtiItem/core/qtiClasses.js new file mode 100644 index 00000000..d4447eff --- /dev/null +++ b/src/qtiItem/core/qtiClasses.js @@ -0,0 +1,50 @@ +export default { + _container: 'taoQtiItem/qtiItem/core/Container', + assessmentItem: 'taoQtiItem/qtiItem/core/Item', + responseProcessing: 'taoQtiItem/qtiItem/core/ResponseProcessing', + _simpleFeedbackRule: 'taoQtiItem/qtiItem/core/response/SimpleFeedbackRule', + stylesheet: 'taoQtiItem/qtiItem/core/Stylesheet', + math: 'taoQtiItem/qtiItem/core/Math', + img: 'taoQtiItem/qtiItem/core/Img', + object: 'taoQtiItem/qtiItem/core/Object', + outcomeDeclaration: 'taoQtiItem/qtiItem/core/variables/OutcomeDeclaration', + responseDeclaration: 'taoQtiItem/qtiItem/core/variables/ResponseDeclaration', + rubricBlock: 'taoQtiItem/qtiItem/core/RubricBlock', + associableHotspot: 'taoQtiItem/qtiItem/core/choices/AssociableHotspot', + gap: 'taoQtiItem/qtiItem/core/choices/Gap', + gapImg: 'taoQtiItem/qtiItem/core/choices/GapImg', + gapText: 'taoQtiItem/qtiItem/core/choices/GapText', + hotspotChoice: 'taoQtiItem/qtiItem/core/choices/HotspotChoice', + hottext: 'taoQtiItem/qtiItem/core/choices/Hottext', + inlineChoice: 'taoQtiItem/qtiItem/core/choices/InlineChoice', + simpleAssociableChoice: 'taoQtiItem/qtiItem/core/choices/SimpleAssociableChoice', + simpleChoice: 'taoQtiItem/qtiItem/core/choices/SimpleChoice', + associateInteraction: 'taoQtiItem/qtiItem/core/interactions/AssociateInteraction', + choiceInteraction: 'taoQtiItem/qtiItem/core/interactions/ChoiceInteraction', + endAttemptInteraction: 'taoQtiItem/qtiItem/core/interactions/EndAttemptInteraction', + extendedTextInteraction: 'taoQtiItem/qtiItem/core/interactions/ExtendedTextInteraction', + gapMatchInteraction: 'taoQtiItem/qtiItem/core/interactions/GapMatchInteraction', + graphicAssociateInteraction: 'taoQtiItem/qtiItem/core/interactions/GraphicAssociateInteraction', + graphicGapMatchInteraction: 'taoQtiItem/qtiItem/core/interactions/GraphicGapMatchInteraction', + graphicOrderInteraction: 'taoQtiItem/qtiItem/core/interactions/GraphicOrderInteraction', + hotspotInteraction: 'taoQtiItem/qtiItem/core/interactions/HotspotInteraction', + hottextInteraction: 'taoQtiItem/qtiItem/core/interactions/HottextInteraction', + inlineChoiceInteraction: 'taoQtiItem/qtiItem/core/interactions/InlineChoiceInteraction', + matchInteraction: 'taoQtiItem/qtiItem/core/interactions/MatchInteraction', + mediaInteraction: 'taoQtiItem/qtiItem/core/interactions/MediaInteraction', + orderInteraction: 'taoQtiItem/qtiItem/core/interactions/OrderInteraction', + prompt: 'taoQtiItem/qtiItem/core/interactions/Prompt', + selectPointInteraction: 'taoQtiItem/qtiItem/core/interactions/SelectPointInteraction', + sliderInteraction: 'taoQtiItem/qtiItem/core/interactions/SliderInteraction', + textEntryInteraction: 'taoQtiItem/qtiItem/core/interactions/TextEntryInteraction', + uploadInteraction: 'taoQtiItem/qtiItem/core/interactions/UploadInteraction', + feedbackBlock: 'taoQtiItem/qtiItem/core/feedbacks/FeedbackBlock', + feedbackInline: 'taoQtiItem/qtiItem/core/feedbacks/FeedbackInline', + modalFeedback: 'taoQtiItem/qtiItem/core/feedbacks/ModalFeedback', + customInteraction: 'taoQtiItem/qtiItem/core/interactions/CustomInteraction', + infoControl: 'taoQtiItem/qtiItem/core/PortableInfoControl', + include: 'taoQtiItem/qtiItem/core/Include', + table: 'taoQtiItem/qtiItem/core/Table', + printedVariable: 'taoQtiItem/qtiItem/core/PrintedVariable', + _tooltip: 'taoQtiItem/qtiItem/core/Tooltip' +}; diff --git a/src/qtiItem/core/response/SimpleFeedbackRule.js b/src/qtiItem/core/response/SimpleFeedbackRule.js new file mode 100644 index 00000000..c50aaa79 --- /dev/null +++ b/src/qtiItem/core/response/SimpleFeedbackRule.js @@ -0,0 +1,134 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technologies SA; + * + */ +import Element from 'taoQtiItem/qtiItem/core/Element'; +import _ from 'lodash'; + +var SimpleFeedbackRule = Element.extend({ + qtiClass: '_simpleFeedbackRule', + serial: '', + rootElement: null, + init: function(serial, feedbackOutcome, feedbackThen, feedbackElse) { + this._super(serial, {}); + + this.condition = 'correct'; + this.comparedOutcome = null; + this.comparedValue = 0.0; + + this.feedbackOutcome = feedbackOutcome; + if (Element.isA(feedbackThen, 'feedback')) { + this.feedbackThen = feedbackThen; + } else { + this.feedbackThen = null; + } + if (Element.isA(feedbackElse, 'feedback')) { + this.feedbackElse = feedbackThen; + } else { + this.feedbackElse = null; + } + }, + setCondition: function(comparedOutcome, condition, comparedValue) { + var _comparedValues = []; + if (Element.isA(comparedOutcome, 'variableDeclaration')) { + switch (condition) { + case 'correct': + case 'incorrect': + if (Element.isA(comparedOutcome, 'responseDeclaration')) { + this.comparedOutcome = comparedOutcome; + this.condition = condition; + } else { + throw 'invalid outcome type: must be a responseDeclaration'; + } + break; + case 'lt': + case 'lte': + case 'equal': + case 'gte': + case 'gt': + if (comparedValue !== null && comparedValue !== undefined) { + this.comparedOutcome = comparedOutcome; + this.condition = condition; + this.comparedValue = comparedValue; + } else { + throw 'compared value must not be null'; + } + break; + case 'choices': + if ( + Element.isA(comparedOutcome, 'responseDeclaration') && + comparedValue !== null && + _.isArray(comparedValue) + ) { + var choices = _.values(comparedOutcome.getInteraction().getChoices()); + this.comparedOutcome = comparedOutcome; + this.condition = condition; + _.each(comparedValue, function(v) { + if (v instanceof Element) { + _comparedValues.push(v); + } else if (_.isString(v)) { + _.each(choices, function(c) { + if (c.attr('identifier') === v) { + _comparedValues.push(c); + return false; //break + } + }); + } + }); + + this.comparedValue = _comparedValues; + } else { + throw 'compared value must not be null'; + } + break; + default: + throw 'unknown condition type : '.condition; + } + } else { + throw 'invalid outcome type: must be a variableDeclaration'; + } + + return this; + }, + setFeedbackElse: function(feedback) { + if (Element.isA(feedback, 'feedback')) { + this.feedbackElse = feedback; + } + }, + toArray: function() { + var val = this.comparedValue; + var _toString = function(v) { + if (val instanceof Element) { + return val.attr('identifier'); + } else { + return val + ''; + } + }; + if (_.isArray(val)) { + val = _.map(val, _toString); + } else { + val = _toString(val); + } + return { + condition: this.condition, + comparedOutcome: this.comparedOutcome.id(), + comparedValue: val + }; + } +}); + +export default SimpleFeedbackRule; diff --git a/src/qtiItem/core/variables/OutcomeDeclaration.js b/src/qtiItem/core/variables/OutcomeDeclaration.js new file mode 100644 index 00000000..bb3b5e88 --- /dev/null +++ b/src/qtiItem/core/variables/OutcomeDeclaration.js @@ -0,0 +1,4 @@ +import VariableDeclaration from 'taoQtiItem/qtiItem/core/variables/VariableDeclaration'; +export default VariableDeclaration.extend({ + qtiClass: 'outcomeDeclaration' +}); diff --git a/src/qtiItem/core/variables/ResponseDeclaration.js b/src/qtiItem/core/variables/ResponseDeclaration.js new file mode 100644 index 00000000..d0872a69 --- /dev/null +++ b/src/qtiItem/core/variables/ResponseDeclaration.js @@ -0,0 +1,85 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technologies SA; + * + */ +import VariableDeclaration from 'taoQtiItem/qtiItem/core/variables/VariableDeclaration'; +import _ from 'lodash'; + +var ResponseDeclaration = VariableDeclaration.extend({ + qtiClass: 'responseDeclaration', + init: function(serial, attributes) { + this._super(serial, attributes); + + //MATCH_CORRECT, MAP_RESPONSE, MAP_RESPONSE_POINT + this.template = ''; //previously called 'howMatch' + + //when template equals ont of the "map" one (MAP_RESPONSE, MAP_RESPONSE_POINT) + this.mappingAttributes = {}; + this.mapEntries = {}; + + //correct response [0..*] + this.correctResponse = null; + + //tao internal usage: + this.feedbackRules = {}; + }, + getFeedbackRules: function() { + return _.values(this.feedbackRules); + }, + getComposingElements: function() { + var elts = this._super(); + elts = _.extend(elts, this.feedbackRules); + return elts; + }, + toArray: function() { + var arr = this._super(); + arr.howMatch = this.template; + arr.correctResponses = this.correctResponse; + arr.mapping = this.mapEntries; + arr.mappingAttributes = this.mappingAttributes; + arr.feedbackRules = _.map(this.feedbackRules, function(rule) { + return rule.toArray(); + }); + return arr; + }, + getInteraction: function() { + var interaction = null; + var responseId = this.id(); + var item = this.getRootElement(); + var interactions = item.getInteractions(); + _.each(interactions, function(i) { + if (i.attributes.responseIdentifier === responseId) { + interaction = i; + return false; //break + } + }); + return interaction; + }, + isCardinality: function(cardinalities) { + var comparison; + if (_.isArray(cardinalities)) { + comparison = cardinalities; + } else if (_.isString(cardinalities)) { + cardinalities = [cardinalities]; + } else { + return false; + } + return _.indexOf(comparison, this.attr('cardinality')) >= 0; + } +}); + +export default ResponseDeclaration; diff --git a/src/qtiItem/core/variables/VariableDeclaration.js b/src/qtiItem/core/variables/VariableDeclaration.js new file mode 100644 index 00000000..6e939444 --- /dev/null +++ b/src/qtiItem/core/variables/VariableDeclaration.js @@ -0,0 +1,46 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014-2017 (original work) Open Assessment Technologies SA; + * + */ +import IdentifiedElement from 'taoQtiItem/qtiItem/core/IdentifiedElement'; + +/** + * It is the top abstract class for all variable classes + * (so not renderable and qtiClass undefined) + */ +var VariableDeclaration = IdentifiedElement.extend({ + init: function init(serial, attributes) { + this._super(serial, attributes); + this.defaultValue = null; + }, + is: function is(qtiClass) { + return qtiClass === 'variableDeclaration' || this._super(qtiClass); + }, + toArray: function toArray() { + var arr = this._super(); + arr.defaultValue = this.defaultValue; + return arr; + }, + setDefaultValue: function setDefaultValue(value) { + this.defaultValue = value; + }, + getDefaultValue: function getDefaultValue() { + return this.defaultValue; + } +}); + +export default VariableDeclaration; diff --git a/src/qtiItem/helper/EventMgr.js b/src/qtiItem/helper/EventMgr.js new file mode 100644 index 00000000..6779549b --- /dev/null +++ b/src/qtiItem/helper/EventMgr.js @@ -0,0 +1,49 @@ +import _ from 'lodash'; + +//@todo : complete with namespace managements +function EventMgr() { + var events = {}; + + this.get = function(event) { + if (event && events[event]) { + return _.clone(events[event]); + } else { + return []; + } + }; + + this.on = function(event, callback) { + var tokens = event.split('.'); + if (tokens[0]) { + var name = tokens.shift(); + events[name] = events[name] || []; + events[name].push({ + ns: tokens, + callback: callback + }); + } + }; + + this.off = function(event) { + if (event && events[event]) { + events[event] = []; + } + }; + + this.trigger = function(event, data) { + if (events[event]) { + _.each(events[event], function(e) { + //@todo check ns: + e.callback.apply( + { + type: event, + ns: [] + }, + data + ); + }); + } + }; +} + +export default EventMgr; diff --git a/src/qtiItem/helper/Parser.js b/src/qtiItem/helper/Parser.js new file mode 100644 index 00000000..7d3c496d --- /dev/null +++ b/src/qtiItem/helper/Parser.js @@ -0,0 +1,17 @@ +/** + * Experimental parser + * + */ +import $ from 'jquery'; + +var Parser = function() { + var _$xml = null; + this.loadXML = function(xml) { + _$xml = $.parseXML(xml); + }; + this.getDOM = function() { + return _$xml; + }; +}; + +export default Parser; diff --git a/src/qtiItem/helper/container.js b/src/qtiItem/helper/container.js new file mode 100644 index 00000000..50c1f504 --- /dev/null +++ b/src/qtiItem/helper/container.js @@ -0,0 +1,200 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2016 (original work) Open Assessment Technologies SA + **/ +import _ from 'lodash'; +import $ from 'jquery'; + +/** + * Prefix used to the variable storage + * @type String + */ +var _prefix = 'x-tao-'; + +/** + * Check if the element is of a qti container type + * + * @private + * @param {Object} element + * @returns {Boolean} + */ +function _checkContainerType(element) { + if (_.isFunction(element.initContainer) && _.isFunction(element.body)) { + return true; + } else { + throw 'the element is not of a container type'; + } +} + +/** + * Get the body element of the container + * + * @private + * @param {Object} element + * @returns {JQuery} + */ +function _getBodyDom(element) { + if (_checkContainerType(element)) { + return $('
        ') + .html(element.body()) + .find('.x-tao-wrapper'); + } +} + +/** + * Add a class to the body element of the qti container + * + * @private + * @param {Object} element + * @param {String} newClass + * @param {String} [oldClass] + */ +function _setBodyDomClass(element, newClass, oldClass) { + if (_checkContainerType(element) && (oldClass || newClass)) { + var $wrapper = $('
        ').html(element.body()); + //set css class to element + _setDomClass($wrapper, newClass, oldClass); + //set to the model + element.body($wrapper.html()); + } +} + +/** + * Switch class to the wrapped DOM + * + * @param {JQuery} $wrapper + * @param {String} newClass + * @param {String} oldClass + * @returns {undefined} + */ +function _setDomClass($wrapper, newClass, oldClass) { + var $bodyDom = $wrapper.find('.x-tao-wrapper'); + if (!$bodyDom.length) { + //create one + $wrapper.wrapInner('
        '); + $bodyDom = $wrapper.find('.x-tao-wrapper'); + } + if (oldClass) { + $bodyDom.removeClass(oldClass); + } + if (newClass) { + $bodyDom.addClass(newClass); + } +} + +/** + * Add manually the encoded information to a dom element + * + * @param {JQuery} $wrapper - the wrapper of the element that will holds the information + * @param {String} dataName - the name of the information + * @param {String} newValue - the new value to be added + * @param {String} [oldValue] - the old value to be removed + * @returns {undefined} + */ +function setEncodedDataToDom($wrapper, dataName, newValue, oldValue) { + _setDomClass($wrapper, _getEncodedDataString(dataName, newValue), _getEncodedDataString(dataName, oldValue)); +} + +/** + * Get the full variable name for the data store + * + * @param {String} dataName + * @param {String} value + * @returns {String} + */ +function _getEncodedDataString(dataName, value) { + if (dataName && value) { + return _prefix + dataName + '-' + value; + } + return ''; +} + +/** + * Set a data string to the element identified by its dataName + * + * @param {Object} element + * @param {String} dataName + * @param {String} newValue + * @returns {undefined} + */ +function setEncodedData(element, dataName, newValue) { + var oldValue = getEncodedData(element, dataName); + return _setBodyDomClass( + element, + _getEncodedDataString(dataName, newValue), + _getEncodedDataString(dataName, oldValue) + ); +} + +/** + * Remove the stored data from the element by its dataName + * + * @param {Object} element + * @param {String} dataName + * @returns {unresolved} + */ +function removeEncodedData(element, dataName) { + var oldValue = getEncodedData(element, dataName); + if (dataName && oldValue) { + _setBodyDomClass(element, '', _getEncodedDataString(dataName, oldValue)); + } +} + +/** + * Check if the stored data exist + * + * @param {Object} element + * @param {String} dataName + * @param {String} value + * @returns {Boolean} + */ +function hasEncodedData(element, dataName, value) { + var $body = _getBodyDom(element); + if ($body && $body.length && dataName && value) { + return $body.hasClass(_getEncodedDataString(dataName, value)); + } + return false; +} + +/** + * Get the encoded data identified by its dataName + * + * @param {Object} element + * @param {String} dataName + * @returns {String} + */ +function getEncodedData(element, dataName) { + var regex, matches; + var $body = _getBodyDom(element); + if (dataName && $body && $body.length && $body.attr('class')) { + regex = new RegExp(_prefix + dataName + '-([a-zA-Z0-9-._]*)'); + matches = $body.attr('class').match(regex); + if (matches) { + return matches[1]; + } + } +} + +/** + * Provide a set of helper functions to set,retirve and manage string data to a container type qti element. + */ +export default { + setEncodedData: setEncodedData, + hasEncodedData: hasEncodedData, + getEncodedData: getEncodedData, + removeEncodedData: removeEncodedData, + setEncodedDataToDom: setEncodedDataToDom +}; diff --git a/src/qtiItem/helper/interactionHelper.js b/src/qtiItem/helper/interactionHelper.js new file mode 100644 index 00000000..cd192b26 --- /dev/null +++ b/src/qtiItem/helper/interactionHelper.js @@ -0,0 +1,100 @@ +/** + * Common helper functions + */ +import _ from 'lodash'; +import Element from 'taoQtiItem/qtiItem/core/Element'; +export default { + convertChoices: function(choices, outputType) { + var ret = [], + _this = this; + + _.each(choices, function(c) { + if (Element.isA(c, 'choice')) { + switch (outputType) { + case 'serial': + ret.push(c.getSerial()); + break; + case 'identifier': + ret.push(c.id()); + break; + default: + ret.push(c); + } + } else if (_.isArray(c)) { + ret.push(_this.convertChoices(c, outputType)); + } + }); + + return ret; + }, + findChoices: function(interaction, choices, inputType) { + var ret = [], + _this = this; + + _.each(choices, function(c) { + var choice; + if (_.isString(c)) { + if (inputType === 'serial') { + choice = interaction.getChoice(c); + if (choice) { + ret.push(choice); + } + } else if (inputType === 'identifier') { + choice = interaction.getChoiceByIdentifier(c); + if (choice) { + ret.push(choice); + } + } else { + ret.push(c); + } + } else if (_.isArray(c)) { + ret.push(_this.findChoices(interaction, c, inputType)); + } else { + ret.push(c); + } + }); + + return ret; + }, + shuffleChoices: function(choices) { + var r = [], //returned array + f = {}, //fixed choices array + j = 0; + + for (var i in choices) { + if (Element.isA(choices[i], 'choice')) { + var choice = choices[i]; + if (choice.attr('fixed')) { + f[j] = choice; + } + r.push(choice); + j++; + } else { + throw 'invalid element in array: is not a qti choice'; + } + } + + for (var n = 0; n < r.length - 1; n++) { + if (f[n]) { + continue; + } + var k = -1; + do { + k = n + Math.floor(Math.random() * (r.length - n)); + } while (f[k]); + var tmp = r[k]; + r[k] = r[n]; + r[n] = tmp; + } + + return r; + }, + serialToIdentifier: function(interaction, choiceSerial) { + var choice = interaction.getChoice(choiceSerial); + if (choice) { + return choice.id(); + } else { + return ''; + } + } +}; diff --git a/src/qtiItem/helper/maxScore.js b/src/qtiItem/helper/maxScore.js new file mode 100644 index 00000000..ef802ac3 --- /dev/null +++ b/src/qtiItem/helper/maxScore.js @@ -0,0 +1,826 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2017 (original work) Open Assessment Technologies SA; + * + */ +import _ from 'lodash'; +import gamp from 'lib/gamp/gamp'; +import responseHelper from 'taoQtiItem/qtiItem/helper/response'; +import OutcomeDeclaration from 'taoQtiItem/qtiCreator/model/variables/OutcomeDeclaration'; + +/** + * This variable allow to globally define if the minCHoice needs to be taken into consideration. + * Standard-wise, it must definitely be considered. + * However, the item delivery lifecycle currently does not consider the minChoice constraint during delivery. + * It is thus currently set to true. After the correct behaviour is implemented, we should remove this variables. + * @type {boolean} + * @private + */ +var _ignoreMinChoice = true; + +var pairExists = function pairExists(collection, pair) { + if (pair.length !== 2) { + return false; + } + return collection[pair[0] + ' ' + pair[1]] || collection[pair[1] + ' ' + pair[0]]; +}; + +export default { + /** + * Set the normal maximum to the item + * @param {Object} item - the standard qti item model object + */ + setNormalMaximum: function setNormalMaximum(item) { + var normalMaximum, + scoreOutcome = item.getOutcomeDeclaration('SCORE'); + + //try setting the computed normal maximum only if the processing type is known, i.e. 'templateDriven' + if (scoreOutcome && item.responseProcessing && item.responseProcessing.processingType === 'templateDriven') { + normalMaximum = _.reduce( + item.getInteractions(), + function(acc, interaction) { + var interactionMaxScore = interaction.getNormalMaximum(); + if (_.isNumber(interactionMaxScore)) { + return gamp.add(acc, interactionMaxScore); + } else { + return false; + } + }, + 0 + ); + + if (_.isNumber(normalMaximum)) { + scoreOutcome.attr('normalMaximum', normalMaximum); + } else { + scoreOutcome.removeAttr('normalMaximum'); + } + } + }, + + /** + * Set the maximum score of the item + * @param {Object} item - the standard qti item model object + */ + setMaxScore: function setMaxScore(item) { + var hasInvalidInteraction = false, + scoreOutcome = item.getOutcomeDeclaration('SCORE'), + customOutcomes, + maxScore, + maxScoreOutcome; + + //try setting the computed normal maximum only if the processing type is known, i.e. 'templateDriven' + if (scoreOutcome && item.responseProcessing && item.responseProcessing.processingType === 'templateDriven') { + maxScore = _.reduce( + item.getInteractions(), + function(acc, interaction) { + var interactionMaxScore = interaction.getNormalMaximum(); + if (_.isNumber(interactionMaxScore)) { + return gamp.add(acc, interactionMaxScore); + } else { + hasInvalidInteraction = true; + return acc; + } + }, + 0 + ); + + customOutcomes = _(item.getOutcomes()).filter(function(outcome) { + return outcome.id() !== 'SCORE' && outcome.id() !== 'MAXSCORE'; + }); + + if (customOutcomes.size()) { + maxScore = customOutcomes.reduce(function(acc, outcome) { + return gamp.add(acc, parseFloat(outcome.attr('normalMaximum') || 0)); + }, maxScore); + } + + if (!hasInvalidInteraction || customOutcomes.size()) { + maxScoreOutcome = item.getOutcomeDeclaration('MAXSCORE'); + if (!maxScoreOutcome) { + //add new outcome + maxScoreOutcome = new OutcomeDeclaration({ + cardinality: 'single', + baseType: 'float' + }); + + //attach the outcome to the item before generating item-level unique id + item.addOutcomeDeclaration(maxScoreOutcome); + maxScoreOutcome.buildIdentifier('MAXSCORE', false); + } + maxScoreOutcome.setDefaultValue(maxScore); + } else { + //remove MAXSCORE: + item.removeOutcome('MAXSCORE'); + } + } + }, + + /** + * Sort an array of associable choices by its matchMax attr value + * @param {Array} choiceCollection + * @returns {Array} + */ + getMatchMaxOrderedChoices: function getMatchMaxOrderedChoices(choiceCollection) { + return _(choiceCollection) + .map(function(choice) { + var matchMax = parseInt(choice.attr('matchMax'), 10); + if (_.isNaN(matchMax)) { + matchMax = 0; + } + return { + matchMax: matchMax === 0 ? Infinity : matchMax, + id: choice.id() + }; + }) + .sortBy('matchMax') + .reverse() + .valueOf(); + }, + + /** + * Compute the maximum score of a "choice" typed interaction + * @param {Object} interaction - a standard interaction model object + * @returns {Number} + */ + choiceInteractionBased: function choiceInteractionBased(interaction, options) { + var responseDeclaration = interaction.getResponseDeclaration(); + var mapDefault = parseFloat(responseDeclaration.mappingAttributes.defaultValue || 0); + var template = responseHelper.getTemplateNameFromUri(responseDeclaration.template); + var max, + maxChoice, + minChoice, + scoreMaps, + requiredChoiceCount, + totalAnswerableResponse, + sortedMapEntries, + i, + missingMapsCount; + + options = _.defaults(options || {}, { maxChoices: 0, minChoices: 0 }); + maxChoice = parseInt(interaction.attr('maxChoices') || options.maxChoices, 10); + minChoice = _ignoreMinChoice ? 0 : parseInt(interaction.attr('minChoices') || options.minChoices, 10); + if (maxChoice && minChoice && maxChoice < minChoice) { + return 0; + } + + if (template === 'MATCH_CORRECT') { + if ( + maxChoice && + _.isArray(responseDeclaration.correctResponse) && + (responseDeclaration.correctResponse.length > maxChoice || + responseDeclaration.correctResponse.length < minChoice) + ) { + //max choice does not enable selecting the correct responses + max = 0; + } else if ( + !responseDeclaration.correctResponse || + (_.isArray(responseDeclaration.correctResponse) && !responseDeclaration.correctResponse.length) + ) { + //no correct response defined -> score always zero + max = 0; + } else { + max = 1; + } + } else if (template === 'MAP_RESPONSE') { + //at least a map entry is required to be valid QTI + if (!responseDeclaration.mapEntries || !_.size(responseDeclaration.mapEntries)) { + return 0; + } + + //prepare constraint params + requiredChoiceCount = minChoice; + totalAnswerableResponse = maxChoice === 0 ? Infinity : maxChoice; + + //sort the score map entries by the score + scoreMaps = _.values(responseDeclaration.mapEntries); + sortedMapEntries = _(scoreMaps) + .map(function(v) { + return parseFloat(v); + }) + .sortBy() + .reverse() + .first(totalAnswerableResponse); + + //if there is not enough map defined, compared to the minChoice constraint, fill in the rest of required choices with the default map + missingMapsCount = minChoice - sortedMapEntries.size(); + _.times(missingMapsCount, function() { + sortedMapEntries.push(mapDefault); + }); + + //if the map default is positive, the optimal strategy involves using as much mapDefault as possible + if (mapDefault && mapDefault > 0) { + if (maxChoice) { + missingMapsCount = maxChoice - sortedMapEntries.size(); + } else { + missingMapsCount = _.size(interaction.getChoices()) - sortedMapEntries.size(); + } + if (missingMapsCount > 0) { + _.times(missingMapsCount, function() { + sortedMapEntries.push(mapDefault); + }); + } + } + + //calculate the maximum reachable score by choice map + max = sortedMapEntries.reduce(function(acc, v) { + var score = v; + if (score < 0 && requiredChoiceCount <= 0) { + //if the score is negative check if we have the choice not to pick it + score = 0; + } + requiredChoiceCount--; + return gamp.add(acc, score); + }, 0); + + //compare the calculated maximum with the mapping upperbound + if (responseDeclaration.mappingAttributes.upperBound) { + max = Math.min(max, parseFloat(responseDeclaration.mappingAttributes.upperBound || 0)); + } + } else if (template === 'MAP_RESPONSE_POINT') { + //map point response processing does not work on choice based interaction + max = 0; + } + return max; + }, + + /** + * Compute the maximum score of a "order" typed interaction + * @param {Object} interaction - a standard interaction model object + * @returns {Number} + */ + orderInteractionBased: function orderInteractionBased(interaction) { + var minChoice = _ignoreMinChoice ? 0 : parseInt(interaction.attr('minChoices') || 0, 10); + var maxChoice = parseInt(interaction.attr('maxChoices') || 0, 10); + var responseDeclaration = interaction.getResponseDeclaration(); + var template = responseHelper.getTemplateNameFromUri(responseDeclaration.template); + var max; + + if (maxChoice && minChoice && maxChoice < minChoice) { + return 0; + } + + if (template === 'MATCH_CORRECT') { + if ( + (_.isArray(responseDeclaration.correctResponse) && + (maxChoice && responseDeclaration.correctResponse.length > maxChoice)) || + (minChoice && responseDeclaration.correctResponse.length < minChoice) + ) { + //max choice does not enable selecting the correct responses + max = 0; + } else if ( + !responseDeclaration.correctResponse || + (_.isArray(responseDeclaration.correctResponse) && !responseDeclaration.correctResponse.length) + ) { + //no correct response defined -> score always zero + max = 0; + } else { + max = 1; + } + } else if (template === 'MAP_RESPONSE' || template === 'MAP_RESPONSE_POINT') { + //map response processing does not work on order based interaction + max = 0; + } + return max; + }, + + /** + * Compute the maximum score of a "associate" typed interaction + * @param {Object} interaction - a standard interaction model object + * @returns {Number} + */ + associateInteractionBased: function associateInteractionBased(interaction, options) { + var responseDeclaration = interaction.getResponseDeclaration(); + var template = responseHelper.getTemplateNameFromUri(responseDeclaration.template); + var maxAssoc = parseInt(interaction.attr('maxAssociations') || 0, 10); + var minAssoc = _ignoreMinChoice ? 0 : parseInt(interaction.attr('minAssociations') || 0, 10); + var mapDefault = parseFloat(responseDeclaration.mappingAttributes.defaultValue || 0); + var max, + requiredAssoc, + totalAnswerableResponse, + usedChoices, + choicesIdentifiers, + sortedMapEntries, + i, + allPossibleMapEntries, + infiniteScoringPair; + + options = _.defaults(options || {}, { possiblePairs: [], checkInfinitePair: false }); + + if (maxAssoc && minAssoc && maxAssoc < minAssoc) { + return 0; + } + + if (template === 'MATCH_CORRECT') { + if ( + !responseDeclaration.correctResponse || + (_.isArray(responseDeclaration.correctResponse) && + (!responseDeclaration.correctResponse.length || + (maxAssoc && responseDeclaration.correctResponse.length > maxAssoc) || + (minAssoc && responseDeclaration.correctResponse.length < minAssoc))) + ) { + //no correct response defined -> score always zero + max = 0; + } else { + max = 1; //is possible until proven otherwise + + //get the list of choices used in map entries + choicesIdentifiers = []; + _.forEach(responseDeclaration.correctResponse, function(pair) { + var choices; + if (!_.isString(pair)) { + return; + } + choices = pair.trim().split(' '); + if (_.isArray(choices) && choices.length === 2) { + choicesIdentifiers.push(choices[0].trim()); + choicesIdentifiers.push(choices[1].trim()); + } + }); + + //check if the choices usage are possible within the constraint defined in the interaction + _.forEach(_.countBy(choicesIdentifiers), function(count, identifier) { + var matchMax; + var choice = interaction.getChoiceByIdentifier(identifier); + if (!choice) { + max = 0; + return false; + } + matchMax = parseInt(choice.attr('matchMax'), 10); + if (matchMax && matchMax < count) { + max = 0; + return false; + } + }); + } + } else if (template === 'MAP_RESPONSE') { + requiredAssoc = minAssoc; + totalAnswerableResponse = maxAssoc === 0 ? Infinity : maxAssoc; + usedChoices = {}; + + //at least a map entry is required to be valid QTI + if (!responseDeclaration.mapEntries || !_.size(responseDeclaration.mapEntries)) { + return 0; + } + + allPossibleMapEntries = _.clone(responseDeclaration.mapEntries); + if (mapDefault && mapDefault > 0) { + _.forEachRight(options.possiblePairs, function(pair) { + if (!pairExists(allPossibleMapEntries, pair)) { + allPossibleMapEntries[pair[0] + ' ' + pair[1]] = mapDefault; + } + }); + } + + //get the sorted list of mapentries ordered by the score + sortedMapEntries = _(allPossibleMapEntries) + .map(function(score, pair) { + return { + score: parseFloat(score), + pair: pair + }; + }) + .sortBy('score') + .reverse() + .filter(function(mapEntry) { + var pair = mapEntry.pair; + var choices, choiceId, choice, _usedChoices; + + if (!_.isString(pair)) { + return false; + } + + //check that the pair is possible in term of matchMax + choices = pair.trim().split(' '); + if (_.isArray(choices) && choices.length === 2) { + //clone the global used choices array to brings the changes in that object first before storing in the actual object + _usedChoices = _.cloneDeep(usedChoices); + + for (i = 0; i < 2; i++) { + choiceId = choices[i]; + + //collect choices usage to check if the pair is possible + if (!_usedChoices[choiceId]) { + choice = interaction.getChoiceByIdentifier(choiceId); + if (!choice) { + //unexisting choice, skip + return false; + } + _usedChoices[choiceId] = { + used: 0, + max: parseInt(choice.attr('matchMax'), 10) + }; + } + if ( + _usedChoices[choiceId].max && + _usedChoices[choiceId].used === _usedChoices[choiceId].max + ) { + //skip + return false; + } else { + _usedChoices[choiceId].used++; + } + } + + //identify the edge case when we can get infinite association pair that create an infinite score + infiniteScoringPair = + infiniteScoringPair || + (options.checkInfinitePair && + mapEntry.score > 0 && + _usedChoices[choices[0]].max === 0 && + _usedChoices[choices[1]].max === 0); + + //update the global used choices array + _.assign(usedChoices, _usedChoices); + return true; + } else { + //is not a correct response pair + return false; + } + }) + .first(totalAnswerableResponse); + + //infinite score => no normalMaximum should be generated for it + if (infiniteScoringPair) { + return false; + } + + //reduce the ordered list of map entries to calculate the max score + max = sortedMapEntries.reduce(function(acc, v) { + var score = v.score; + if (v.score < 0 && requiredAssoc <= 0) { + //if the score is negative check if we have the choice not to pick it + score = 0; + } + requiredAssoc--; + return gamp.add(acc, score); + }, 0); + + //compare the calculated maximum with the mapping upperbound + if (responseDeclaration.mappingAttributes.upperBound) { + max = Math.min(max, parseFloat(responseDeclaration.mappingAttributes.upperBound || 0)); + } + } else if (template === 'MAP_RESPONSE_POINT') { + max = 0; + } + return max; + }, + + /** + * Compute the maximum score of a "gap match" typed interaction + * @param {Object} interaction - a standard interaction model object + * @returns {Number} + */ + gapMatchInteractionBased: function gapMatchInteractionBased(interaction) { + var responseDeclaration = interaction.getResponseDeclaration(); + var template = responseHelper.getTemplateNameFromUri(responseDeclaration.template); + var maxAssoc = 0; + var minAssoc = 0; + var mapDefault = parseFloat(responseDeclaration.mappingAttributes.defaultValue || 0); + var max, + skippableWrongResponse, + totalAnswerableResponse, + usedChoices, + usedGaps, + group1, + group2, + allPossibleMapEntries; + var getMatchMaxOrderedChoices = function getMatchMaxOrderedChoices(choiceCollection) { + return _(choiceCollection) + .map(function(choice) { + return { + matchMax: choice.attr('matchMax') === 0 ? Infinity : choice.attr('matchMax') || 0, + id: choice.id() + }; + }) + .sortBy('matchMax') + .reverse() + .valueOf(); + }; + var calculatePossiblePairs = function calculatePossiblePairs(gapMatchInteraction) { + //get max number of pairs + var pairs = []; + var matchSet1 = getMatchMaxOrderedChoices(gapMatchInteraction.getChoices()); + var matchSet2 = getMatchMaxOrderedChoices(gapMatchInteraction.getGaps()); + + _.forEach(matchSet1, function(choice1) { + _.forEach(matchSet2, function(choice2) { + pairs.push([choice1.id, choice2.id]); + }); + }); + + return pairs; + }; + + if (template === 'MATCH_CORRECT') { + if ( + !responseDeclaration.correctResponse || + (_.isArray(responseDeclaration.correctResponse) && !responseDeclaration.correctResponse.length) + ) { + //no correct response defined -> score always zero + max = 0; + } else { + max = 1; //is possible until proven otherwise + group1 = []; + group2 = []; + _.forEach(responseDeclaration.correctResponse, function(pair) { + var choices; + if (!_.isString(pair)) { + return; + } + choices = pair.trim().split(' '); + if (_.isArray(choices) && choices.length === 2) { + group1.push(choices[0].trim()); + group2.push(choices[1].trim()); + } + }); + + _.forEach(_.countBy(group1), function(count, identifier) { + var choice = interaction.getChoiceByIdentifier(identifier); + var matchMax = parseInt(choice.attr('matchMax'), 10); + if (matchMax && matchMax < count) { + max = 0; + return false; + } + }); + + _.forEach(_.countBy(group2), function(count) { + var matchMax = 1; //match max for a gap is always 1 + if (matchMax && matchMax < count) { + max = 0; + return false; + } + }); + } + } else if (template === 'MAP_RESPONSE') { + skippableWrongResponse = minAssoc === 0 ? Infinity : minAssoc; + totalAnswerableResponse = maxAssoc === 0 ? Infinity : maxAssoc; + usedChoices = {}; + usedGaps = {}; + + //at least a map entry is required to be valid QTI + if (!responseDeclaration.mapEntries || !_.size(responseDeclaration.mapEntries)) { + return 0; + } + + allPossibleMapEntries = _.clone(responseDeclaration.mapEntries); + if (mapDefault && mapDefault > 0) { + _.forEachRight(calculatePossiblePairs(interaction), function(pair) { + if (!pairExists(allPossibleMapEntries, pair)) { + allPossibleMapEntries[pair[0] + ' ' + pair[1]] = mapDefault; + } + }); + } + + max = _(allPossibleMapEntries) + .map(function(score, pair) { + return { + score: parseFloat(score), + pair: pair + }; + }) + .sortBy('score') + .reverse() + .filter(function(mapEntry) { + var pair = mapEntry.pair; + var _usedChoices = _.cloneDeep(usedChoices); + var choices, choiceId, gapId, choice; + + if (!_.isString(pair)) { + return false; + } + + choices = pair.trim().split(' '); + if (_.isArray(choices) && choices.length === 2) { + choiceId = choices[0]; + gapId = choices[1]; + if (!_usedChoices[choiceId]) { + choice = interaction.getChoiceByIdentifier(choiceId); + if (!choice) { + //inexisting choice, skip + return false; + } + _usedChoices[choiceId] = { + used: 0, + max: parseInt(choice.attr('matchMax'), 10) + }; + } + if (_usedChoices[choiceId].max && _usedChoices[choiceId].used === _usedChoices[choiceId].max) { + //skip + return false; + } + _usedChoices[choiceId].used++; + + if (!usedGaps[gapId]) { + usedGaps[gapId] = { + used: 0, + max: 1 + }; + } + if (usedGaps[gapId].max && usedGaps[gapId].used === usedGaps[gapId].max) { + //skip + return false; + } + usedGaps[gapId].used++; + + //if an only if it is ok, we merge the temporary used choices array into the global one + _.assign(usedChoices, _usedChoices); + return true; + } else { + //is not a correct response pair + return false; + } + }) + .first(totalAnswerableResponse) + .reduce(function(acc, v) { + var score = v.score; + if (score >= 0) { + return acc + score; + } else if (skippableWrongResponse > 0) { + skippableWrongResponse--; + return acc; + } else { + return acc + score; + } + }, 0); + + //console.log(usedChoices, allPossibleMapEntries, sortedMaps); + + //compare the calculated maximum with the mapping upperbound + if (responseDeclaration.mappingAttributes.upperBound) { + max = Math.min(max, parseFloat(responseDeclaration.mappingAttributes.upperBound || 0)); + } + } else if (template === 'MAP_RESPONSE_POINT') { + max = false; + } + return max; + }, + + /** + * Compute the maximum score of a "select point" typed interaction + * @param {Object} interaction - a standard interaction model object + * @returns {Number} + */ + selectPointInteractionBased: function selectPointInteractionBased(interaction) { + var maxChoice = parseInt(interaction.attr('maxChoices'), 10); + var minChoice = _ignoreMinChoice ? 0 : parseInt(interaction.attr('minChoices'), 10); + var responseDeclaration = interaction.getResponseDeclaration(); + var template = responseHelper.getTemplateNameFromUri(responseDeclaration.template); + var max, skippableWrongResponse, totalAnswerableResponse; + + if (template === 'MATCH_CORRECT' || template === 'MAP_RESPONSE') { + //such templates are not allowed + return 0; + } else if (template === 'MAP_RESPONSE_POINT') { + //calculate the maximum reachable score by choice map + skippableWrongResponse = minChoice === 0 ? Infinity : minChoice; + totalAnswerableResponse = maxChoice === 0 ? Infinity : maxChoice; + + max = _(responseDeclaration.mapEntries) + .map(function(v) { + return parseFloat(v.mappedValue); + }) + .sortBy() + .reverse() + .first(totalAnswerableResponse) + .reduce(function(acc, v) { + if (v >= 0) { + return acc + v; + } else if (skippableWrongResponse > 0) { + skippableWrongResponse--; + return acc; + } else { + return acc + v; + } + }, 0); + max = parseFloat(max); + + //compare the calculated maximum with the mapping upperbound + if (responseDeclaration.mappingAttributes.upperBound) { + max = Math.min(max, parseFloat(responseDeclaration.mappingAttributes.upperBound || 0)); + } + } + return max; + }, + + /** + * Compute the maximum score of a "slider" typed interaction + * @param {Object} interaction - a standard interaction model object + * @returns {Number} + */ + sliderInteractionBased: function sliderInteractionBased(interaction) { + var responseDeclaration = interaction.getResponseDeclaration(); + var template = responseHelper.getTemplateNameFromUri(responseDeclaration.template); + var max, scoreMaps; + + if (template === 'MATCH_CORRECT') { + if ( + !responseDeclaration.correctResponse || + (_.isArray(responseDeclaration.correctResponse) && !responseDeclaration.correctResponse.length) + ) { + //no correct response defined -> score always zero + max = 0; + } else { + max = 1; + } + } else if (template === 'MAP_RESPONSE') { + //at least a map entry is required to be valid QTI + if (!responseDeclaration.mapEntries || !_.size(responseDeclaration.mapEntries)) { + return 0; + } + + //calculate the maximum reachable score by choice map + scoreMaps = _.values(responseDeclaration.mapEntries); + max = _(scoreMaps) + .map(function(v) { + return parseFloat(v); + }) + .max(); + max = parseFloat(max); + + //compare the calculated maximum with the mapping upperbound + if (responseDeclaration.mappingAttributes.upperBound) { + max = Math.min(max, parseFloat(responseDeclaration.mappingAttributes.upperBound || 0)); + } + } else if (template === 'MAP_RESPONSE_POINT') { + max = 0; + } + return max; + }, + + /** + * Compute the maximum score of a "text entry" typed interaction + * @param {Object} interaction - a standard interaction model object + * @returns {Number} + */ + textEntryInteractionBased: function textEntryInteractionBased(interaction) { + var responseDeclaration = interaction.getResponseDeclaration(); + var template = responseHelper.getTemplateNameFromUri(responseDeclaration.template); + var max, scoreMaps; + + /** + * Check that a response is possible or not according to the defined patternmask + * @param {String} value + * @returns {Boolean} + */ + var isPossibleResponse = function isPossibleResponse(value) { + var patternMask = interaction.attr('patternMask'); + if (patternMask) { + return !!value.match(new RegExp(patternMask)); + } else { + //no restriction by pattern so always possible + return true; + } + }; + + if (template === 'MATCH_CORRECT') { + if ( + !responseDeclaration.correctResponse || + (_.isArray(responseDeclaration.correctResponse) && !responseDeclaration.correctResponse.length) + ) { + //no correct response defined -> score always zero + max = 0; + } else { + max = isPossibleResponse(responseDeclaration.correctResponse[0]) ? 1 : 0; + } + } else if (template === 'MAP_RESPONSE') { + //at least a map entry is required to be valid QTI + if (!responseDeclaration.mapEntries || !_.size(responseDeclaration.mapEntries)) { + return 0; + } + + //calculate the maximum reachable score by choice map + scoreMaps = _.values( + _.filter(responseDeclaration.mapEntries, function(score, key) { + return isPossibleResponse(key); + }) + ); + max = _(scoreMaps) + .map(function(v) { + return parseFloat(v); + }) + .max(); + max = parseFloat(max); + + //compare the calculated maximum with the mapping upperbound + if (responseDeclaration.mappingAttributes.upperBound) { + max = Math.min(max, parseFloat(responseDeclaration.mappingAttributes.upperBound || 0)); + } + } else if (template === 'MAP_RESPONSE_POINT') { + max = 0; + } + return max; + } +}; diff --git a/src/qtiItem/helper/modalFeedback.js b/src/qtiItem/helper/modalFeedback.js new file mode 100644 index 00000000..c037387d --- /dev/null +++ b/src/qtiItem/helper/modalFeedback.js @@ -0,0 +1,176 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2016 (original work) Open Assessment Technologies SA; + * + * @author Alexander Zagovorichev + */ + +import $ from 'jquery'; +import _ from 'lodash'; +import pci from 'taoQtiItem/qtiItem/helper/pci'; +import containerHelper from 'taoQtiItem/qtiItem/helper/container'; + +/** + * Provide the feedbackMessage signature to check if the feedback contents should be considered equals + * + * @param {type} feedback + * @returns {String} + */ +var getFeedbackMessageSignature = function getFeedbackMessageSignature(feedback) { + return ('' + feedback.body() + feedback.attr('title')) + .toLowerCase() + .trim() + .replace(/x-tao-[a-zA-Z0-9\-._\s]*/g, ''); +}; + +/** + * Extract the display information for an interaction-related feedback + * + * @private + * @param {Object} interaction - a qti interaction object + * @returns {Object} Object containing useful display information + */ +var extractDisplayInfo = function extractDisplayInfo(interaction) { + var $interactionContainer = interaction.getContainer(); + var responseIdentifier = interaction.attr('responseIdentifier'); + var messageGroupId, $displayContainer; + + if (interaction.is('inlineInteraction')) { + $displayContainer = $interactionContainer.closest('[class*=" col-"], [class^="col-"]'); + messageGroupId = $displayContainer.attr('data-messageGroupId'); + if (!messageGroupId) { + //generate a messageFromId + messageGroupId = _.uniqueId('inline_message_group_'); + $displayContainer.attr('data-messageGroupId', messageGroupId); + } + } else { + messageGroupId = responseIdentifier; + $displayContainer = $interactionContainer; + } + + return { + responseIdentifier: responseIdentifier, + interactionContainer: $interactionContainer, + displayContainer: $displayContainer, + messageGroupId: messageGroupId, + order: -1 + }; +}; + +/** + * Get interaction display information sorted in the order of appearance within the item + * + * @param {Object} item + * @returns {Array} + */ +var getInteractionsDisplayInfo = function getInteractionsDisplayInfo(item) { + var interactionsDisplayInfo = []; + var $itemContainer = item.getContainer(); + var interactionOrder = 0; + + //extract all interaction related information needed to display their + _.forEach(item.getComposingElements(), function(element) { + if (element.is('interaction')) { + interactionsDisplayInfo.push(extractDisplayInfo(element)); + } + }); + + //sort interactionsDisplayInfo on the item level + $itemContainer.find('.qti-interaction').each(function() { + var self = this; + _.forEach(interactionsDisplayInfo, function(_interactionInfo) { + if (_interactionInfo.interactionContainer[0] === self) { + _interactionInfo.order = interactionOrder; + return false; + } + }); + interactionOrder++; + }); + interactionsDisplayInfo = _.sortBy(interactionsDisplayInfo, 'order'); + + return interactionsDisplayInfo; +}; + +/** + * Returns feedbacks according to the given itemSession variables + * + * @param {Object} item - the standard tao qti item object + * @param {Object} itemSession - session information containing the list of feedbacks to display + * @returns {Array} renderingFeedbacks - feedbacks to be displayed + */ +var getFeedbacks = function getFeedbacks(item, itemSession) { + var messages = {}; + var $itemContainer = item.getContainer(); + var $itemBody = $('.qti-itemBody', $itemContainer); + var interactionsDisplayInfo = getInteractionsDisplayInfo(item); + var renderingQueue = []; + + _.forEach(item.modalFeedbacks, function(feedback) { + var feedbackIds, message, $container, comparedOutcome, _currentMessageGroupId, interactionInfo; + var outcomeIdentifier = feedback.attr('outcomeIdentifier'); + var order = -1; + + //verify if the feedback should be displayed + if (itemSession[outcomeIdentifier]) { + //is the feedback in the list of feedbacks to be displayed ? + feedbackIds = pci.getRawValues(itemSession[outcomeIdentifier]); + if (!_.contains(feedbackIds, feedback.id())) { + return true; //continue with next feedback + } + + //which group of feedbacks (interaction related) the feedback belongs to ? + message = getFeedbackMessageSignature(feedback); + comparedOutcome = containerHelper.getEncodedData(feedback, 'relatedOutcome'); + interactionInfo = _.find(interactionsDisplayInfo, { responseIdentifier: comparedOutcome }); + if (comparedOutcome && interactionInfo) { + $container = interactionInfo.displayContainer; + _currentMessageGroupId = interactionInfo.messageGroupId; + order = interactionInfo.order; + } else { + $container = $itemBody; + _currentMessageGroupId = '__item__'; + } + //is this message already displayed ? + if (!messages[_currentMessageGroupId]) { + messages[_currentMessageGroupId] = []; + } + + if (_.contains(messages[_currentMessageGroupId], message)) { + return true; //continue + } else { + messages[_currentMessageGroupId].push(message); + } + + //ok, display feedback + renderingQueue.push({ + feedback: feedback, + $container: $container, + order: order + }); + } + }); + + renderingQueue = _.sortBy(renderingQueue, 'order'); + + return renderingQueue; +}; + +/** + * Provide helper function for collecting feedbacks + */ +export default { + getFeedbacks: getFeedbacks +}; diff --git a/src/qtiItem/helper/pci.js b/src/qtiItem/helper/pci.js new file mode 100644 index 00000000..b9271169 --- /dev/null +++ b/src/qtiItem/helper/pci.js @@ -0,0 +1,16 @@ +import _ from 'lodash'; + +var pci = { + getRawValues: function(pciVar) { + if (_.isPlainObject(pciVar)) { + if (pciVar.base !== undefined) { + return _.values(pciVar.base); + } else if (pciVar.list) { + return _.values(pciVar.list); + } + } + throw 'unsupported type '; + } +}; + +export default pci; diff --git a/src/qtiItem/helper/rendererConfig.js b/src/qtiItem/helper/rendererConfig.js new file mode 100644 index 00000000..769d58d6 --- /dev/null +++ b/src/qtiItem/helper/rendererConfig.js @@ -0,0 +1,33 @@ +import _ from 'lodash'; +import $ from 'jquery'; + +var rendererConfigHelper = {}; + +rendererConfigHelper.getOptionsFromArguments = function(args) { + var options = { + data: {}, + placeholder: null, + subclass: '', + renderer: null + }; + + _.each(args, function(arg) { + if (arg) { + if (arg.isRenderer) { + options.renderer = arg; + } else if (arg instanceof $ && arg.length) { + options.placeholder = arg; + } else if (_.isString(arg)) { + options.subclass = arg; + } else if (_.isPlainObject(arg)) { + options.data = arg; + } else { + console.log('invalid arg', arg, args); + } + } + }); + + return options; +}; + +export default rendererConfigHelper; diff --git a/src/qtiItem/helper/response.js b/src/qtiItem/helper/response.js new file mode 100644 index 00000000..c6e69f4f --- /dev/null +++ b/src/qtiItem/helper/response.js @@ -0,0 +1,56 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014-2017 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + * + */ +import _ from 'lodash'; + +var _templateNames = { + MATCH_CORRECT: 'http://www.imsglobal.org/question/qti_v2p1/rptemplates/match_correct', + MAP_RESPONSE: 'http://www.imsglobal.org/question/qti_v2p1/rptemplates/map_response', + MAP_RESPONSE_POINT: 'http://www.imsglobal.org/question/qti_v2p1/rptemplates/map_response_point', + NONE: 'no_response_processing' +}; + +export default { + isUsingTemplate: function isUsingTemplate(response, tpl) { + if (_.isString(tpl)) { + if (tpl === response.template || _templateNames[tpl] === response.template) { + return true; + } + } + return false; + }, + isValidTemplateName: function isValidTemplateName(tplName) { + return !!this.getTemplateUriFromName(tplName); + }, + getTemplateUriFromName: function getTemplateUriFromName(tplName) { + if (_templateNames[tplName]) { + return _templateNames[tplName]; + } + return ''; + }, + getTemplateNameFromUri: function getTemplateNameFromUri(tplUri) { + var tplName = ''; + _.forIn(_templateNames, function(uri, name) { + if (uri === tplUri) { + tplName = name; + return false; + } + }); + return tplName; + } +}; diff --git a/src/qtiItem/helper/simpleParser.js b/src/qtiItem/helper/simpleParser.js new file mode 100644 index 00000000..4aae61b2 --- /dev/null +++ b/src/qtiItem/helper/simpleParser.js @@ -0,0 +1,193 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import util from 'taoQtiItem/qtiItem/helper/util'; +import Loader from 'taoQtiItem/qtiItem/core/Loader'; + +var _parsableElements = ['img', 'object', 'printedVariable']; +var _qtiClassNames = { + rubricblock: 'rubricBlock', + printedvariable: 'printedVariable' +}; +var _qtiAttributesNames = { + powerform: 'powerForm', + mappingindicator: 'mappingIndicator' +}; + +var _defaultOptions = { + ns: { + math: '', + include: 'xi' + }, + loaded: null, + model: null +}; + +var parser; + +function _getElementSelector(qtiClass, ns) { + return ns ? ns + '\\:' + qtiClass + ',' + qtiClass : qtiClass; +} + +function getQtiClassFromXmlDom($node) { + var qtiClass = $node.prop('tagName').toLowerCase(); + + //remove ns : + qtiClass = qtiClass.replace(/.*:/, ''); + + return _qtiClassNames[qtiClass] ? _qtiClassNames[qtiClass] : qtiClass; +} + +function buildElement($elt) { + var qtiClass = getQtiClassFromXmlDom($elt); + + var elt = { + qtiClass: qtiClass, + serial: util.buildSerial(qtiClass + '_'), + attributes: {} + }; + + $.each($elt[0].attributes, function() { + var attrName; + if (this.specified) { + attrName = _qtiAttributesNames[this.name] || this.name; + elt.attributes[attrName] = this.value; + } + }); + + return elt; +} + +function buildMath($elt, options) { + var elt = buildElement($elt); + + //set annotations: + elt.annotations = {}; + $elt.find(_getElementSelector('annotation', options.ns.math)).each(function() { + var $annotation = $(this); + var encoding = $annotation.attr('encoding'); + if (encoding) { + elt.annotations[encoding] = _.unescape($annotation.html()); + } + $annotation.remove(); + }); + + //set math xml + elt.mathML = $elt.html(); + + //set ns: + elt.ns = { + name: 'm', + uri: 'http://www.w3.org/1998/Math/MathML' //@todo : remove hardcoding there + }; + + return elt; +} + +function buildTooltip(targetHtml, contentId, contentHtml) { + var qtiClass = '_tooltip'; + + return { + elements: {}, + qtiClass: qtiClass, + serial: util.buildSerial(qtiClass + '_'), + attributes: { + 'aria-describedby': contentId + }, + content: contentHtml, + body: { + elements: {}, + serial: util.buildSerial('container'), + body: targetHtml + } + }; +} + +function parseContainer($container, options) { + var ret = { + serial: util.buildSerial('_container_'), + body: '', + elements: {} + }; + + _.each(_parsableElements, function(qtiClass) { + $container.find(qtiClass).each(function() { + var $qtiElement = $(this); + var element = buildElement($qtiElement, options); + + ret.elements[element.serial] = element; + $qtiElement.replaceWith(_placeholder(element)); + }); + }); + + $container.find(_getElementSelector('math', options.ns.math)).each(function() { + var $qtiElement = $(this); + var element = buildMath($qtiElement, options); + + ret.elements[element.serial] = element; + $qtiElement.replaceWith(_placeholder(element)); + }); + + $container.find(_getElementSelector('include', options.ns.include)).each(function() { + var $qtiElement = $(this); + var element = buildElement($qtiElement, options); + + ret.elements[element.serial] = element; + $qtiElement.replaceWith(_placeholder(element)); + }); + + $container.find('[data-role="tooltip-target"]').each(function() { + var element, + $target = $(this), + $content, + contentId = $target.attr('aria-describedBy'), + contentHtml; + + if (contentId) { + $content = $container.find('#' + contentId); + if ($content.length) { + contentHtml = $content.html(); + + element = buildTooltip($target.html(), contentId, contentHtml); + + ret.elements[element.serial] = element; + $target.replaceWith(_placeholder(element)); + $content.remove(); + } + } + }); + + ret.body = $container.html(); + + return ret; +} + +function _placeholder(element) { + return '{{' + element.serial + '}}'; +} + +parser = { + parse: function(xmlStr, opts) { + var options = _.merge(_.clone(_defaultOptions), opts || {}); + + var $container = $(xmlStr); + + var element = buildElement($container, options); + + var data = parseContainer($container, options); + + var loader; + + if (!_.isUndefined(data.body)) { + element.body = data; + } + + if (_.isFunction(options.loaded) && options.model) { + loader = new Loader().setClassesLocation(options.model); + loader.loadAndBuildElement(element, options.loaded); + } + + return element; + } +}; + +export default parser; diff --git a/src/qtiItem/helper/util.js b/src/qtiItem/helper/util.js new file mode 100644 index 00000000..d91ef269 --- /dev/null +++ b/src/qtiItem/helper/util.js @@ -0,0 +1,185 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2015 (original work) Open Assessment Technologies SA + * + */ + +/** + * Common basic util functions + */ +import _ from 'lodash'; + +var util = { + buildSerial: function buildSerial(prefix) { + var id = prefix || ''; + var chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + for (var i = 0; i < 22; i++) { + id += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return id; + }, + + /** + * Generates an id for a Qti element (the generation is different from identifier) + * @param {Object} item - the element related item + * @param {String} prefix - identifier prefix + * @returns {String} the identifier + * @throws {TypeError} if there is no item + */ + buildId: function buildId(item, prefix) { + var id; + var usedIds; + var i = 1; + var suffix = ''; + var exists = false; + + if (!item) { + throw new TypeError('A item is required to generate a unique identifier'); + } + + usedIds = item.getUsedIds(); + + do { + exists = false; + id = prefix + suffix; + if (_.contains(usedIds, id)) { + exists = true; + suffix = '_' + i; + i++; + } + } while (exists); + + return id; + }, + + /** + * Generates an identifier for a Qti element + * @param {Object} item - the element related item + * @param {String} prefix - identifier prefix + * @param {Boolean} [useSuffix = true] - add a "_ + index" to the identifier + * @returns {String} the identifier + * @throws {TypeError} if there is no item + */ + buildIdentifier: function buildIdentifier(item, prefix, useSuffix) { + var id; + var usedIds; + var suffix = ''; + var i = 1; + var exists = false; + + if (!item) { + throw new TypeError('A item is required to generate a unique identifier'); + } + + if (!prefix) { + throw new TypeError('Prefix is required to build an identifier'); + } + + usedIds = item.getUsedIdentifiers(); + useSuffix = _.isUndefined(useSuffix) ? true : useSuffix; + + if (prefix) { + prefix = prefix + .replace(/_[0-9]+$/gi, '_') //detect incremental id of type choice_12, response_3, etc. + .replace(/[^a-zA-Z0-9_]/gi, '_') + .replace(/(_)+/gi, '_'); + if (useSuffix) { + suffix = '_' + i; + } + } + + do { + exists = false; + id = prefix + suffix; + if (usedIds[id]) { + exists = true; + suffix = '_' + i; + i++; + } + } while (exists); + + return id; + }, + + findInCollection: function findInCollection(element, collectionNames, searchedSerial) { + var found = null; + + if (_.isString(collectionNames)) { + collectionNames = [collectionNames]; + } + + if (_.isArray(collectionNames)) { + _.each(collectionNames, function(collectionName) { + //get collection to search in (resolving case like interaction.choices.0 + var collection = element; + _.each(collectionName.split('.'), function(nameToken) { + collection = collection[nameToken]; + }); + var elt = collection[searchedSerial]; + + if (elt) { + found = { parent: element, element: elt }; + return false; //break the each loop + } + + //search inside each elements: + _.each(collection, function(elt) { + if (_.isFunction(elt.find)) { + found = elt.find(searchedSerial); + if (found) { + return false; //break the each loop + } + } + }); + + if (found) { + return false; //break the each loop + } + }); + } else { + throw new Error('invalid argument : collectionNames must be an array or a string'); + } + + return found; + }, + addMarkupNamespace: function addMarkupNamespace(markup, ns) { + if (ns) { + markup = markup.replace(/<(\/)?([a-z:]+)(\s?)([^><]*)>/g, function($0, $1, $2, $3, $4) { + if ($2.indexOf(':') > 0) { + return $0; + } + $1 = $1 || ''; + $3 = $3 || ''; + return '<' + $1 + ns + ':' + $2 + $3 + $4 + '>'; + }); + return markup; + } + return markup; + }, + removeMarkupNamespaces: function removeMarkupNamespace(markup) { + return markup.replace(/<(\/)?(\w*):([^>]*)>/g, '<$1$3>'); + }, + getMarkupUsedNamespaces: function getMarkupUsedNamespaces(markup) { + var namespaces = []; + markup.replace(/<(\/)?(\w*):([^>]*)>/g, function(original, slash, ns, node) { + namespaces.push(ns); + return '<' + slash + node + '>'; + }); + return _.uniq(namespaces); + } +}; + +export default util; diff --git a/src/qtiItem/helper/xincludeLoader.js b/src/qtiItem/helper/xincludeLoader.js new file mode 100644 index 00000000..088e2a7a --- /dev/null +++ b/src/qtiItem/helper/xincludeLoader.js @@ -0,0 +1,37 @@ +import $ from 'jquery'; +import _ from 'lodash'; +import simpleParser from 'taoQtiItem/qtiItem/helper/simpleParser'; +import Loader from 'taoQtiItem/qtiItem/core/Loader'; + +function load(xinclude, baseUrl, callback) { + var href = xinclude.attr('href'); + if (href && baseUrl) { + //require xml : + require(['text!' + baseUrl + href], function(stimulusXml) { + var $wrapper = $('
        ').html(stimulusXml); + var $sampleXMLrootNode = $wrapper.children(); + var $stimulus = $('').append($sampleXMLrootNode); + var mathNs = 'm'; //for 'http://www.w3.org/1998/Math/MathML' + var data = simpleParser.parse($stimulus, { + ns: { + math: mathNs + } + }); + + new Loader().loadElement(xinclude, data, function() { + if (_.isFunction(callback)) { + var loadedClasses = this.getLoadedClasses(); + loadedClasses.push('_container'); //the _container class is always required + callback(xinclude, data, loadedClasses); + } + }); + }, function(err) { + //in case the file does not exist + callback(xinclude, false, []); + }); + } +} + +export default { + load: load +}; diff --git a/src/qtiItem/helper/xmlNsHandler.js b/src/qtiItem/helper/xmlNsHandler.js new file mode 100644 index 00000000..89049845 --- /dev/null +++ b/src/qtiItem/helper/xmlNsHandler.js @@ -0,0 +1,107 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2017 (original work) Open Assessment Technologies SA + */ + +/** + * XML namespace handling + */ + +/** + * Elements that need to be prefixed + * + * @see http://www.imsglobal.org/xsd/qti/qtiv2p2/imsqti_v2p2.xsd + * @type {string} + */ +var prefixed = 'article|aside|bdi|figure|footer|header|nav|rb|rp|rt|rtc|ruby|section'; + +/** + * Find a possibly existing prefix + * + * @param namespaces + * @param html5Ns + * @returns {*} + */ +function getPrefix(namespaces, html5Ns) { + var key; + for (key in namespaces) { + if (namespaces[key] === html5Ns) { + return key; + } + } + return 'qh5'; +} + +export default { + /** + * Remove prefix to make sure elements are properly displayed + * + * @param body + */ + stripNs: function stripNs(body) { + var pattern = '([\\w]+)\\:(' + prefixed + ')'; + var openRegEx = new RegExp('(<' + pattern + ')', 'gi'); + var closeRegEx = new RegExp('(<\\/' + pattern + '>)', 'gi'); + return body.replace(openRegEx, '<$3').replace(closeRegEx, ''); + }, + + /** + * Add a prefix to those elements that require one + * + * @param xml + * @param namespaces + * @returns {*} + */ + restoreNs: function restoreNs(xml, namespaces) { + var xmlRe = new RegExp('(<(' + prefixed + ')[^>]*>|<\\/(' + prefixed + ')>)', 'gi'); + var tagRe = new RegExp('((<)(' + prefixed + ')([^>]*)(>)|(<\\/)(' + prefixed + ')(>))', 'i'); + var xmlMatch = xml.match(xmlRe); + var imsXsd = 'http://www.imsglobal.org/xsd'; + var html5Ns = imsXsd + '/imsqtiv2p2_html5_v1p0'; + var prefix = getPrefix(namespaces, html5Ns); + var prefixAtt = 'xmlns:' + prefix + '="' + html5Ns + '"'; + var i = xmlMatch ? xmlMatch.length : 0; + var tagMatch; + + if (!xmlMatch) { + return xml; + } + + while (i--) { + tagMatch = xmlMatch[i].match(tagRe); + xml = xml.replace( + xmlMatch[i], + tagMatch[5] + ? '<' + prefix + ':' + tagMatch[3] + tagMatch[4] + '>' + : '' + ); + } + + // we found matches but no namespace has been set + if (xmlMatch.length && xml.indexOf(prefixAtt) === -1) { + xml = xml.replace(' + */ +import Mixin from 'taoQtiItem/qtiItem/mixin/Mixin'; +import Container from 'taoQtiItem/qtiItem/mixin/Container'; +import _ from 'lodash'; + +var methods = {}; +_.extend(methods, Container.methods); +_.extend(methods, { + initContainer: function(body) { + Container.methods.initContainer.call(this, body); + this.bdy.contentModel = 'table'; + } +}); + +export default { + augment: function(targetClass) { + Mixin.augment(targetClass, methods); + }, + methods: methods +}; diff --git a/src/qtiItem/mixin/CustomElement.js b/src/qtiItem/mixin/CustomElement.js new file mode 100644 index 00000000..7d1e1f8c --- /dev/null +++ b/src/qtiItem/mixin/CustomElement.js @@ -0,0 +1,68 @@ +import Mixin from 'taoQtiItem/qtiItem/mixin/Mixin'; +import _ from 'lodash'; + +var methods = { + prop: function(name, value) { + if (name) { + if (value !== undefined) { + this.properties[name] = value; + } else { + if (typeof name === 'object') { + for (var prop in name) { + this.prop(prop, name[prop]); + } + } else if (typeof name === 'string') { + if (this.properties[name] === undefined) { + return undefined; + } else { + return this.properties[name]; + } + } + } + } + return this; + }, + removeProp: function(propNames) { + var _this = this; + if (typeof propNames === 'string') { + propNames = [propNames]; + } + _.each(propNames, function(propName) { + delete _this.attributes[propName]; + }); + return this; + }, + getProperties: function() { + return _.clone(this.properties); + }, + getMarkupNamespace: function() { + if (this.markupNs && this.markupNs.name && this.markupNs.uri) { + return _.clone(this.markupNs); + } else { + var relatedItem = this.getRootElement(); + if (relatedItem) { + //set the default one: + relatedItem.namespaces[this.defaultMarkupNsName] = this.defaultMarkupNsUri; + return { + name: this.defaultMarkupNsName, + uri: this.defaultMarkupNsUri + }; + } + } + + return {}; + }, + setMarkupNamespace: function(name, uri) { + this.markupNs = { + name: name, + uri: uri + }; + } +}; + +export default { + augment: function(targetClass) { + Mixin.augment(targetClass, methods); + }, + methods: methods +}; diff --git a/src/qtiItem/mixin/Mixin.js b/src/qtiItem/mixin/Mixin.js new file mode 100644 index 00000000..e5966889 --- /dev/null +++ b/src/qtiItem/mixin/Mixin.js @@ -0,0 +1,20 @@ +//@todo : need refactoring of qti item mixin with lodash.mixin() +export default { + augment: function(targetClass, methods, options) { + if (typeof targetClass === 'function' && typeof methods === 'object') { + for (var methodName in methods) { + if (!Object.hasOwnProperty(targetClass.prototype, methodName)) { + targetClass.prototype[methodName] = methods[methodName]; + } else { + if (options && options.append) { + var _parent = targetClass.prototype[methodName]; + targetClass.prototype[methodName] = function() { + methods[methodName].apply(this, arguments); + return _parent.apply(this, arguments); + }; + } + } + } + } + } +}; diff --git a/src/qtiItem/mixin/NamespacedElement.js b/src/qtiItem/mixin/NamespacedElement.js new file mode 100644 index 00000000..a6b40a5b --- /dev/null +++ b/src/qtiItem/mixin/NamespacedElement.js @@ -0,0 +1,66 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2015 (original work) Open Assessment Technologies SA ; + * + */ +import Mixin from 'taoQtiItem/qtiItem/mixin/Mixin'; +import _ from 'lodash'; + +var methods = { + getNamespace: function() { + var relatedItem; + var namespaces; + var ns; + + if (this.ns && (this.ns.name || this.ns.uri)) { + return _.clone(this.ns); + } else { + relatedItem = this.getRootElement(); + if (relatedItem) { + namespaces = relatedItem.getNamespaces(); + for (ns in namespaces) { + if (namespaces[ns].indexOf(this.nsUriFragment) > 0) { + return { + name: ns, + uri: namespaces[ns] + }; + } + } + //if no ns found in the item, set the default one! + relatedItem.namespaces[this.defaultNsName] = this.defaultNsUri; + return { + name: this.defaultNsName, + uri: this.defaultNsUri + }; + } + } + + return {}; + }, + setNamespace: function(name, uri) { + this.ns = { + name: name, + uri: uri + }; + } +}; + +export default { + augment: function(targetClass) { + Mixin.augment(targetClass, methods); + }, + methods: methods +}; diff --git a/src/runner/provider/manager/picManager.js b/src/runner/provider/manager/picManager.js new file mode 100644 index 00000000..e41bc835 --- /dev/null +++ b/src/runner/provider/manager/picManager.js @@ -0,0 +1,441 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2015 (original work) Open Assessment Technologies SA ; + */ +/** + * @author Jean-Sébastien Conan + */ +import $ from 'jquery'; +import _ from 'lodash'; +import Element from 'taoQtiItem/qtiItem/core/Element'; + +/** + * The template of a PicManager instance + * @type {picManager} + */ +var picManager = { + /** + * Creates a manager for a particular PIC + * + * @param {Object} pic + * @param {QtiItem} item + * @returns {picManager} + */ + init: function init(pic, item) { + if (Element.isA(pic, 'infoControl')) { + this._pic = pic; + } + + if (Element.isA(item, 'assessmentItem')) { + this._item = item; + } + + return this; + }, + + /** + * Gets the managed PIC + * + * @returns {Object} the descriptor of the PIC + */ + getPic: function getPic() { + return this._pic; + }, + + /** + * Gets the related Item + * + * @returns {QtiItem} the Item + */ + getItem: function getItem() { + return this._item; + }, + + /** + * Gets the PIC serial + * @returns {String} + */ + getSerial: function getSerial() { + return this._pic && this._pic.serial; + }, + + /** + * Gets the PIC type identifier + * @returns {String} + */ + getTypeIdentifier: function getTypeIdentifier() { + return this._pic && this._pic.typeIdentifier; + }, + + /** + * Gets the underlying DOM element of the managed PIC + * @returns {{pic: (jQuery), tool: (jQuery), button: (jQuery), broken: (Boolean))}|*} An object providing the underlying DOM elements of the PIC and its tool + */ + getDom: function getDom() { + if (!this._dom) { + var serial = this.getSerial(); + var pic, tool; + + if (serial) { + pic = $('[data-serial="' + serial + '"]'); + if (pic.length) { + tool = $('[data-pic-serial="' + serial + '"]'); + + if (!tool.length) { + tool = pic.children().first(); + } + + this._dom = { + pic: pic, + tool: tool, + button: tool.find('.sts-button'), + broken: pic.is(':empty') // tells if the tool has been moved outside of the PIC + }; + } + } + } + + return this._dom; + }, + + /** + * Enables the PIC. + * @fires enable + * @returns {picManager} + */ + enable: function enable() { + // @todo: find a better solution for disabling/enabling a PIC + var dom = this.getDom(); + if (dom) { + // just remove the disabled state and destroy the disable mask + dom.button.removeClass('disabled'); + dom.tool.find('.sts-button-disable-mask').remove(); + + this.disabled = false; + this.trigger('enable'); + } + + return this; + }, + + /** + * Disables the PIC + * @fires disable + * @returns {picManager} + */ + disable: function disable() { + // @todo: find a better solution for disabling/enabling a PIC + var dom = this.getDom(); + var button; + if (dom) { + // set a disabled state by adding a CSS class, then mask the button with a top-level element + button = dom.button.addClass('disabled'); + + $('
        ') + .appendTo(dom.tool) + .offset(button.offset()) + .width(button.outerWidth()) + .height(button.outerHeight()); + + // also hide any sub component + dom.tool.find('.sts-container, .sts-menu-container').addClass('sts-hidden-container'); + + this.disabled = true; + this.trigger('disable'); + } + + return this; + }, + + /** + * Shows the PIC + * @fires show + * @returns {picManager} + */ + show: function show() { + var dom = this.getDom(); + if (dom) { + dom.tool.show(); + + this.trigger('show'); + } + + return this; + }, + + /** + * Hides the PIC + * @fires hide + * @returns {picManager} + */ + hide: function hide() { + var dom = this.getDom(); + if (dom) { + dom.tool.hide(); + + this.trigger('hide'); + } + + return this; + }, + + /** + * Triggers an event on the underlying DOM element + * @param {String} eventName + * @returns {picManager} + */ + trigger: function trigger(eventName) { + var dom = this.getDom(); + var args = _.rest(arguments, 1); + + args.unshift(this); + + if (dom) { + // trigger the event, if the tool has been moved outside of the PIC, trigger also the event on the PIC + dom.tool.trigger(eventName, args); + if (dom.broken) { + dom.pic.trigger(eventName, args); + } + } + + return this; + } +}; + +/** + * The template of a PicManagerCollection instance + * @type {picManagerCollection} + */ +var picManagerCollection = { + /** + * Creates the collection of PIC from an Item + * + * @param {QtiItem} item + * @returns {picManagerCollection} + */ + init: function init(item) { + if (Element.isA(item, 'assessmentItem')) { + this._item = item; + } + + return this; + }, + + /** + * Gets the list of PIC managers for the PIC provided by the running item. + * + * @param {Boolean} [force] Force a list rebuild + * @returns {Array} Returns the list of managers for the provided PIC + */ + getList: function getList(force) { + var self = this; + + // build the list if empty + if (force || !self._list) { + self._map = {}; + self._list = []; + if (self._item) { + _.forEach(self._item.getElements(), function(element) { + var manager; + + if (Element.isA(element, 'infoControl')) { + manager = managerFactory(element, self._item); + self._list.push(manager); + self._map[element.serial] = manager; + self._map[element.typeIdentifier] = manager; + } + }); + } + } + + return this._list; + }, + + /** + * Gets the manager of the first PIC matching the identifier from the list provided by the running item. + * + * @param {String} picId The PIC typeIdentifier or serial + * @returns {Object} The manager of the PIC + */ + getPic: function getPic(picId) { + this.getList(); + return this._map[picId]; + }, + + /** + * Executes an action on a particular PIC from the running item. + * @param {String} picId The PIC typeIdentifier or serial + * @param {String} action The name of the action to call + * @returns {*} Returns the action result + */ + execute: function execute(picId, action) { + var pic = this.getPic(picId); + if (pic && pic[action]) { + return pic[action].apply(pic, _.rest(arguments, 2)); + } + }, + + /** + * Executes an action on each PIC provided by the running item. + * @param {String} action The name of the action to call + * @param {Function} [filter] An optional filter to reduce the list + * @returns {picManagerCollection} + */ + executeAll: function executeAll(action, filter) { + var args = _.rest(arguments, 2); + var cb; + + if (typeof filter === 'function') { + cb = function(pic) { + if (filter.call(pic, pic) && pic[action]) { + pic[action].apply(pic, args); + } + }; + } else { + cb = function(pic) { + if (pic[action]) { + pic[action].apply(pic, args); + } + }; + } + + return this.each(cb); + }, + + /** + * Calls a callback function on each listed PIC from the running item. + * @param {Function} cb The callback function to apply on each listed PIC + * @returns {picManagerCollection} + */ + each: function each(cb) { + _.forEach(this.getList(), cb); + return this; + }, + + /** + * Enables a PIC provided by the running item. + * + * @param {String} picId The PIC typeIdentifier or serial + * @returns {picManagerCollection} + */ + enablePic: function enablePic(picId) { + this.execute(picId, 'enable'); + return this; + }, + + /** + * Disables a PIC provided by the running item. + * + * @param {String} picId The PIC typeIdentifier or serial + * @returns {picManagerCollection} + */ + disablePic: function disablePic(picId) { + this.execute(picId, 'disable'); + return this; + }, + + /** + * Shows a PIC provided by the running item. + * + * @param {String} picId The PIC typeIdentifier or serial + * @returns {picManagerCollection} + */ + showPic: function showPic(picId) { + this.execute(picId, 'show'); + return this; + }, + + /** + * Hides a PIC provided by the running item. + * + * @param {String} picId The PIC typeIdentifier or serial + * @returns {picManagerCollection} + */ + hidePic: function hidePic(picId) { + this.execute(picId, 'hide'); + return this; + }, + + /** + * Enables all PIC provided by the running item. + * + * @param {Function} [filter] An optional filter to reduce the list of PIC to enable + * @returns {picManagerCollection} + */ + enableAll: function enableAll(filter) { + this.executeAll('enable', filter); + return this; + }, + + /** + * Disables all PIC provided by the running item. + * + * @param {Function} [filter] An optional filter to reduce the list of PIC to disable + * @returns {picManagerCollection} + */ + disableAll: function disableAll(filter) { + this.executeAll('disable', filter); + return this; + }, + + /** + * Shows all PIC provided by the running item. + * + * @param {Function} [filter] An optional filter to reduce the list of PIC to show + * @returns {picManagerCollection} + */ + showAll: function showAll(filter) { + this.executeAll('show', filter); + return this; + }, + + /** + * Hides all PIC provided by the running item. + * + * @param {Function} [filter] An optional filter to reduce the list of PIC to hide + * @returns {picManagerCollection} + */ + hideAll: function hideAll(filter) { + this.executeAll('hide', filter); + return this; + } +}; + +/** + * Creates a PIC manager for a particular Item. + * @param {Object} pic + * @param {QtiItem} item + * @returns {picManager} Returns the instance of the PIC manager + */ +var managerFactory = function managerFactory(pic, item) { + var manager = _.clone(picManager, true); + return manager.init(pic, item); +}; + +/** + * Creates a PIC manager for a particular Item. + * @param {QtiItem} item + * @returns {picManager} Returns the instance of the PIC manager + */ +var collectionFactory = function collectionFactory(item) { + var collection = _.clone(picManagerCollection, true); + return collection.init(item); +}; + +export default { + collection: collectionFactory, + manager: managerFactory +}; diff --git a/src/runner/provider/manager/userModules.js b/src/runner/provider/manager/userModules.js new file mode 100644 index 00000000..2d87c594 --- /dev/null +++ b/src/runner/provider/manager/userModules.js @@ -0,0 +1,54 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2016 (original work) Open Assessment Technologies SA ; + */ +/** + * @author Christophe Noël + */ +import _ from 'lodash'; +import module from 'module'; +import Promise from 'core/promise'; + +export default { + /** + * Load user modules defined in the module config + * @param {Array} [userModules] - manually specify user modules to load instead of getting them from the module config + * @returns {Promise} + */ + load: function load(userModules) { + var config = module.config(); + + if (!userModules || !_.isArray(userModules)) { + if (config && config.userModules && _.isArray(config.userModules)) { + userModules = config.userModules; + } else { + userModules = []; + } + } + return new Promise(function(resolve, reject) { + require(userModules, function() { + _.forEach(arguments, function(dependency) { + if (dependency && _.isFunction(dependency.exec)) { + dependency.exec(); + } + }); + resolve(); + }, function(err) { + reject(err.message); + }); + }); + } +}; diff --git a/src/runner/provider/qti.js b/src/runner/provider/qti.js new file mode 100644 index 00000000..d6587021 --- /dev/null +++ b/src/runner/provider/qti.js @@ -0,0 +1,274 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + * + */ + +/** + * @author Bertrand Chevrier + */ +import $ from 'jquery'; +import _ from 'lodash'; +import context from 'context'; +import Promise from 'core/promise'; +import QtiLoader from 'taoQtiItem/qtiItem/core/Loader'; +import Element from 'taoQtiItem/qtiItem/core/Element'; +import ciRegistry from 'taoQtiItem/portableElementRegistry/ciRegistry'; +import icRegistry from 'taoQtiItem/portableElementRegistry/icRegistry'; +import sideLoadingProviderFactory from 'taoQtiItem/portableElementRegistry/provider/sideLoadingProviderFactory'; +import QtiRenderer from 'taoQtiItem/qtiCommonRenderer/renderers/Renderer'; +import picManager from 'taoQtiItem/runner/provider/manager/picManager'; +import userModules from 'taoQtiItem/runner/provider/manager/userModules'; +import modalFeedbackHelper from 'taoQtiItem/qtiItem/helper/modalFeedback'; +import 'taoItems/assets/manager'; + +var timeout = (context.timeout > 0 ? context.timeout + 1 : 30) * 1000; + +/** + * @exports taoQtiItem/runner/provider/qti + */ +var qtiItemRuntimeProvider = { + init: function(itemData, done) { + var self = this; + + var rendererOptions = _.merge( + { + assetManager: this.assetManager + }, + _.pick(this.options, ['themes', 'preload']) + ); + + this._renderer = new QtiRenderer(rendererOptions); + this._loader = new QtiLoader(); + + this._loader.loadItemData(itemData, function(item) { + if (!item) { + return self.trigger('error', 'Unable to load item from the given data.'); + } + + self._item = item; + self._renderer.load(function() { + self._item.setRenderer(this); + + done(); + }, this.getLoadedClasses()); + }); + }, + + render: function(elt, done, options) { + var self = this; + + options = _.defaults(options || {}, { state: {} }); + + if (this._item) { + try { + //render item html + elt.innerHTML = this._item.render({}); + } catch (e) { + self.trigger('error', 'Error in template rendering : ' + e.message); + } + try { + if (options.portableElements) { + //if the option to directly load portable elements is provided, use only this one + if (options.portableElements.pci) { + ciRegistry.resetProviders(); + ciRegistry.registerProvider( + 'pciDeliveryProvider', + sideLoadingProviderFactory(options.portableElements.pci) + ); + } + if (options.portableElements.pic) { + icRegistry.resetProviders(); + icRegistry.registerProvider( + 'picDeliveryProvider', + sideLoadingProviderFactory(options.portableElements.pic) + ); + } + } + + // Race between postRendering and timeout + // postRendering waits for everything to be resolved or one reject + Promise.race([ + Promise.all(this._item.postRender(options)), + new Promise(function(resolve, reject) { + _.delay( + reject, + timeout, + new Error( + 'It seems that there is an error during item loading. The error has been reported. Please continue with the test.' + ) + ); + }) + ]) + .then(function() { + $(elt) + .off('responseChange') + .on('responseChange', function() { + self.trigger('statechange', self.getState()); + self.trigger('responsechange', self.getResponses()); + }) + .off('endattempt') + .on('endattempt', function(e, responseIdentifier) { + self.trigger('endattempt', responseIdentifier || e.originalEvent.detail); + }) + .off('themechange') + .on('themechange', function(e, themeName) { + var themeLoader = self._renderer.getThemeLoader(); + themeName = themeName || e.originalEvent.detail; + if (themeLoader) { + themeLoader.change(themeName); + } + }); + + /** + * Lists the PIC provided by this item. + * @event qti#listpic + */ + self.trigger('listpic', picManager.collection(self._item)); + + return userModules.load().then(done); + }) + .catch(function(err) { + done(); // in case of postRendering issue, we are also done + self.trigger( + 'warning', + 'Error in post rendering : ' + err instanceof Error ? err.message : err + ); + }); + } catch (err) { + self.trigger('error', 'Error in post rendering : ' + err.message); + } + } + }, + + /** + * Clean up stuffs + */ + clear: function(elt, done) { + var self = this; + + if (self._item) { + Promise.all( + this._item.getInteractions().map(function(interaction) { + return interaction.clear(); + }) + ) + .then(function() { + self._item.clear(); + + $(elt) + .off('responseChange') + .off('endattempt') + .off('themechange') + .off('feedback') + .empty(); + + if (self._renderer) { + self._renderer.unload(); + } + + self._item = null; + }) + .then(done) + .catch(function(err) { + self.trigger('error', 'Something went wrong while destroying an interaction: ' + err.message); + }); + } else { + done(); + } + }, + + /** + * Get state implementation. + * @returns {Object} that represents the state + */ + getState: function getState() { + var state = {}; + if (this._item) { + //get the state from interactions + _.forEach(this._item.getInteractions(), function(interaction) { + state[interaction.attr('responseIdentifier')] = interaction.getState(); + }); + + //get the state from infoControls + _.forEach(this._item.getElements(), function(element) { + if (Element.isA(element, 'infoControl') && element.attr('id')) { + state.pic = state.pic || {}; + state.pic[element.attr('id')] = element.getState(); + } + }); + } + return state; + }, + + /** + * Set state implementation. + * @param {Object} state - the state + */ + setState: function setState(state) { + if (this._item && state) { + //set interaction state + _.forEach(this._item.getInteractions(), function(interaction) { + var id = interaction.attr('responseIdentifier'); + if (id && state[id]) { + interaction.setState(state[id]); + } + }); + + //set info control state + if (state.pic) { + _.forEach(this._item.getElements(), function(element) { + if (Element.isA(element, 'infoControl') && state.pic[element.attr('id')]) { + element.setState(state.pic[element.attr('id')]); + } + }); + } + } + }, + + getResponses: function() { + var responses = {}; + if (this._item) { + _.reduce( + this._item.getInteractions(), + function(res, interaction) { + responses[interaction.attr('responseIdentifier')] = interaction.getResponse(); + return responses; + }, + responses + ); + } + return responses; + }, + + renderFeedbacks: function(feedbacks, itemSession, done) { + var self = this; + + var _renderer = self._item.getRenderer(); + var _loader = new QtiLoader(self._item); + + // loading feedbacks from response into the current item + _loader.loadElements(feedbacks, function(item) { + _renderer.load(function() { + var renderingQueue = modalFeedbackHelper.getFeedbacks(item, itemSession); + + done(renderingQueue); + }, this.getLoadedClasses()); + }); + } +}; + +export default qtiItemRuntimeProvider; diff --git a/src/runner/qtiItemRunner.js b/src/runner/qtiItemRunner.js new file mode 100644 index 00000000..10b55001 --- /dev/null +++ b/src/runner/qtiItemRunner.js @@ -0,0 +1,33 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ + +/** + * @author Bertrand Chevrier + */ +import itemRunner from 'taoItems/runner/api/itemRunner'; +import qtiRuntimeProvider from 'taoQtiItem/runner/provider/qti'; + +//register the QTI Provider +itemRunner.register('qti', qtiRuntimeProvider); + +/** + * Expose the itemRunner with the QTI provider registered + * @exports taoQtiItem/runner/qtiItemRunner + */ +export default itemRunner; From a69ee23bc50d30aaa281e7219912d119de0b4e6f Mon Sep 17 00:00:00 2001 From: Tamas Besenyei Date: Wed, 12 Jun 2019 14:02:34 +0200 Subject: [PATCH 03/45] move more src deps --- build/rollup.config.js | 21 +- .../assetManager/portableAssetStrategy.js | 54 ++ src/portableElementRegistry/ciRegistry.js | 38 + .../factory/ciRegistry.js | 58 ++ .../factory/factory.js | 479 +++++++++++ .../factory/icRegistry.js | 29 + src/portableElementRegistry/icRegistry.js | 38 + .../provider/localManifestProvider.js | 157 ++++ .../provider/sideLoadingProviderFactory.js | 50 ++ src/qtiCommonRenderer/renderers/Img.js | 2 +- src/qtiCommonRenderer/renderers/Math.js | 2 +- .../renderers/PortableInfoControl.js | 2 +- .../interactions/AssociateInteraction.js | 2 +- .../interactions/ExtendedTextInteraction.js | 2 +- .../GraphicAssociateInteraction.js | 2 +- .../GraphicGapMatchInteraction.js | 2 +- .../interactions/GraphicOrderInteraction.js | 2 +- .../interactions/HotspotInteraction.js | 2 +- .../interactions/MediaInteraction.js | 2 +- .../interactions/PortableCustomInteraction.js | 2 +- .../interactions/SelectPointInteraction.js | 2 +- src/qtiRunner/core/QtiRunner.js | 314 +++++++ src/qtiRunner/core/Renderer.js | 772 ++++++++++++++++++ src/qtiRunner/modalFeedback/inlineRenderer.js | 430 ++++++++++ src/qtiRunner/modalFeedback/modalRenderer.js | 145 ++++ .../tpl/inlineModalFeedbackDeliveryButton.tpl | 6 + .../tpl/inlineModalFeedbackPreviewButton.tpl | 5 + src/runner/provider/manager/userModules.js | 1 - src/runner/provider/qti.js | 2 +- 29 files changed, 2600 insertions(+), 23 deletions(-) create mode 100644 src/portableElementRegistry/assetManager/portableAssetStrategy.js create mode 100644 src/portableElementRegistry/ciRegistry.js create mode 100644 src/portableElementRegistry/factory/ciRegistry.js create mode 100644 src/portableElementRegistry/factory/factory.js create mode 100644 src/portableElementRegistry/factory/icRegistry.js create mode 100644 src/portableElementRegistry/icRegistry.js create mode 100644 src/portableElementRegistry/provider/localManifestProvider.js create mode 100644 src/portableElementRegistry/provider/sideLoadingProviderFactory.js create mode 100755 src/qtiRunner/core/QtiRunner.js create mode 100755 src/qtiRunner/core/Renderer.js create mode 100644 src/qtiRunner/modalFeedback/inlineRenderer.js create mode 100644 src/qtiRunner/modalFeedback/modalRenderer.js create mode 100644 src/qtiRunner/tpl/inlineModalFeedbackDeliveryButton.tpl create mode 100644 src/qtiRunner/tpl/inlineModalFeedbackPreviewButton.tpl diff --git a/build/rollup.config.js b/build/rollup.config.js index 07e2f44c..c2443e4e 100644 --- a/build/rollup.config.js +++ b/build/rollup.config.js @@ -69,6 +69,7 @@ export default inputs.map(input => { 'i18n', 'module', 'context', + 'async', 'raphael', 'scale.raphael', @@ -79,17 +80,19 @@ export default inputs.map(input => { 'interact', 'select2', 'ckeditor', + 'iframeNotifier', - 'taoQtiItem/portableElementRegistry/assetManager/portableAssetStrategy', - 'taoQtiItem/portableElementRegistry/ciRegistry', - 'taoQtiItem/portableElementRegistry/icRegistry', - 'taoQtiItem/qtiRunner/core/Renderer', + // 'taoQtiItem/portableElementRegistry/assetManager/portableAssetStrategy', //move the whole directory + // 'taoQtiItem/portableElementRegistry/ciRegistry', + // 'taoQtiItem/portableElementRegistry/icRegistry', + // 'taoQtiItem/portableElementRegistry/provider/sideLoadingProviderFactory', + // 'taoQtiItem/qtiRunner/core/Renderer', //move it 'taoQtiItem/qtiCreator/model/variables/OutcomeDeclaration', - 'taoQtiItem/portableElementRegistry/provider/sideLoadingProviderFactory', + 'taoQtiItem/qtiCreator/helper/qtiElements', - 'taoItems/runner/api/itemRunner', - 'taoItems/assets/manager', - 'taoItems/assets/strategies', + // 'taoItems/runner/api/itemRunner', + // 'taoItems/assets/manager', + // 'taoItems/assets/strategies', 'qtiInfoControlContext', 'qtiCustomInteractionContext', @@ -98,7 +101,7 @@ export default inputs.map(input => { ], plugins: [ cssResolve(), - externalAlias(['core', 'util', 'ui']), + externalAlias(['core', 'util', 'ui', 'taoItems']), alias({ resolve: ['.js', '.json', '.tpl'], ...aliases diff --git a/src/portableElementRegistry/assetManager/portableAssetStrategy.js b/src/portableElementRegistry/assetManager/portableAssetStrategy.js new file mode 100644 index 00000000..8eede2e1 --- /dev/null +++ b/src/portableElementRegistry/assetManager/portableAssetStrategy.js @@ -0,0 +1,54 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2016 (original work) Open Assessment Technologies SA; + */ + +import ciRegistry from 'taoQtiItem/portableElementRegistry/ciRegistry'; +import icRegistry from 'taoQtiItem/portableElementRegistry/icRegistry'; + +function getBaseUrlByIdentifier(typeIdentifier) { + if (ciRegistry.get(typeIdentifier)) { + return ciRegistry.getBaseUrl(typeIdentifier); + } + if (icRegistry.get(typeIdentifier)) { + return icRegistry.getBaseUrl(typeIdentifier); + } + return typeIdentifier; +} + +//strategy to resolve portable info control and portable interactions paths. +//It should never be reached in the stack the usual way and should be called only using resolveBy. +export default { + name: 'portableElementLocation', + handle: function handlePortableElementLocation(url) { + if (url.file === url.path) { + return getBaseUrlByIdentifier(url.file); + } else if (url.source === url.relative) { + return url.relative.replace(/^(\.\/)?([a-z_0-9]+)\/(.*)/i, function( + fullmatch, + $1, + typeIdentifier, + relPath + ) { + var runtimeLocation = getBaseUrlByIdentifier(typeIdentifier); + if (runtimeLocation) { + return runtimeLocation + '/' + relPath; + } + return fullmatch; + }); + } + } +}; diff --git a/src/portableElementRegistry/ciRegistry.js b/src/portableElementRegistry/ciRegistry.js new file mode 100644 index 00000000..66c40fcc --- /dev/null +++ b/src/portableElementRegistry/ciRegistry.js @@ -0,0 +1,38 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2016 (original work) Open Assessment Technologies SA; + * + */ +import _ from 'lodash'; +import ciRegistry from 'taoQtiItem/portableElementRegistry/factory/ciRegistry'; +import module from 'module'; + +//create a preregistered singleton of ciRegistry +var registry = ciRegistry(); +var providers = []; +var config = module.config(); + +if (config && config.providers) { + providers = config.providers; +} + +_.each(providers, function(provider) { + if (provider.name && provider.module) { + registry.registerProvider(provider.module); + } +}); + +export default registry; diff --git a/src/portableElementRegistry/factory/ciRegistry.js b/src/portableElementRegistry/factory/ciRegistry.js new file mode 100644 index 00000000..f93acc8b --- /dev/null +++ b/src/portableElementRegistry/factory/ciRegistry.js @@ -0,0 +1,58 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2016 (original work) Open Assessment Technologies SA; + * + */ +import _ from 'lodash'; +import portableElementRegistry from 'taoQtiItem/portableElementRegistry/factory/factory'; +import qtiElements from 'taoQtiItem/qtiCreator/helper/qtiElements'; + +/** + * Create a ney interaction registry instance + * interaction registry need to register newly loaded creator hooks in the list of available qti authoring elements + * + * @returns {Object} registry instance + */ +export default function customInteractionRegistry() { + return portableElementRegistry({ + getAuthoringData: function getAuthoringData(typeIdentifier, options) { + var pciModel; + options = _.defaults(options || {}, { version: 0, enabledOnly: false }); + pciModel = this.get(typeIdentifier, options.version); + if ( + pciModel && + pciModel.creator && + pciModel.creator.hook && + pciModel.creator.icon && + (pciModel.enabled || !options.enabledOnly) + ) { + return { + label: pciModel.label, //currently no translation available + icon: pciModel.creator.icon.replace(new RegExp('^' + typeIdentifier + '/'), pciModel.baseUrl), + short: pciModel.short, + description: pciModel.description, + qtiClass: 'customInteraction.' + pciModel.typeIdentifier, //custom interaction is block type + tags: _.union(['Custom Interactions'], pciModel.tags) + }; + } + } + }).on('creatorsloaded', function() { + var creators = this.getLatestCreators(); + _.forEach(creators, function(creator, typeIdentifier) { + qtiElements.classes['customInteraction.' + typeIdentifier] = { parents: ['customInteraction'], qti: true }; + }); + }); +} diff --git a/src/portableElementRegistry/factory/factory.js b/src/portableElementRegistry/factory/factory.js new file mode 100644 index 00000000..07276b01 --- /dev/null +++ b/src/portableElementRegistry/factory/factory.js @@ -0,0 +1,479 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2016 (original work) Open Assessment Technologies SA; + * + */ +import _ from 'lodash'; + +import eventifier from 'core/eventifier'; + +var _requirejs = window.require; +var _defaultLoadingOptions = { + reload: false +}; + +var loadModuleConfig = function loadModuleConfig(manifest) { + return new Promise(function(resolve, reject) { + var requireConfigAliases = {}; + var baseUrl; + var reqConfigs = []; + var modules = {}; + + if (!manifest || !manifest.runtime) { + return reject('invalid manifest'); + } + + baseUrl = manifest.baseUrl; + + if (_.isArray(manifest.runtime.config) && manifest.runtime.config.length) { + _.forEach(manifest.runtime.config, function(pciConfig) { + if (_.isString(pciConfig)) { + reqConfigs.push('json!' + baseUrl + '/' + pciConfig); + } else { + if (pciConfig.data) { + modules = _.defaults(modules, pciConfig.data.paths || {}); + } else if (pciConfig.file) { + reqConfigs.push('json!' + baseUrl + '/' + pciConfig.file); + } + } + }); + } + + require(reqConfigs, function() { + var runtimeModules = {}; + + requireConfigAliases[manifest.typeIdentifier] = baseUrl; + + if (manifest.model === 'IMSPCI') { + modules = _.reduce( + arguments, + function(acc, conf) { + return _.defaults(acc, conf.paths || {}); + }, + modules + ); + + _.forEach(manifest.runtime.modules || {}, function(paths, id) { + if (paths && (_.isString(paths) || (_.isArray(paths) && paths.length))) { + runtimeModules[id] = paths; + } + }); + + modules = _.merge(modules, runtimeModules); + + _.forEach(modules, function(paths, id) { + paths = _.isArray(paths) ? paths : [paths]; + requireConfigAliases[id] = _.map(paths, function(path) { + return baseUrl + '/' + path.replace(/\.js$/, ''); + }); + }); + } + + resolve(requireConfigAliases); + }, reject); + }); +}; + +/** + * Evaluate if the given object is a proper portable element provider + * @param {Object} provider + * @returns {Boolean} + */ +var isPortableElementProvider = function isPortableElementProvider(provider) { + return provider && _.isFunction(provider.load); +}; + +export default function portableElementRegistry(methods) { + var _loaded = false; + var __providers = {}; + + /** + * The portable element registry instance + * @typedef {portableElementRegistry} + */ + return eventifier( + _.defaults(methods || {}, { + _registry: {}, + + /** + * Get a register portable element + * @param {String} typeIdentifier + * @param {String} version + * @returns {Object} + */ + get: function get(typeIdentifier, version) { + if (this._registry[typeIdentifier]) { + //check version + if (version) { + return _.find(this._registry[typeIdentifier], { version: version }); + } else { + //latest + return _.last(this._registry[typeIdentifier]); + } + } + }, + + /** + * Register a portable element provider + * @param moduleName + * @param {String|Object }provider - the portable provider object or module name + * @returns {portableElementRegistry} + */ + registerProvider: function registerProvider(moduleName, provider) { + __providers[moduleName] = isPortableElementProvider(provider) ? provider : null; + _loaded = false; + return this; + }, + + /** + * Clear all previously registered providers + * @returns {portableElementRegistry} + */ + resetProviders: function resetProviders() { + __providers = {}; + _loaded = false; + return this; + }, + + /** + * Load the providers + * @param {Object} [options] + * @param {Boolean} [options.reload] - force to reload the registered providers + * @returns {Promise} + */ + loadProviders: function loadProviders(options) { + var self = this; + var loadPromise; + + if (_loaded && !options.reload) { + loadPromise = Promise.resolve([]); + } else { + loadPromise = new Promise(function(resolve, reject) { + var providerLoadingStack = []; + _.forIn(__providers, function(provider, id) { + if (provider === null) { + providerLoadingStack.push(id); + } + }); + _requirejs( + providerLoadingStack, + function() { + _.each([].slice.call(arguments), function(provider) { + if (isPortableElementProvider(provider)) { + __providers[providerLoadingStack.shift()] = provider; + } + }); + resolve(__providers); + _loaded = true; + self.trigger('providersloaded', __providers); + }, + reject + ); + }); + } + + return loadPromise; + }, + + /** + * Get all versions of all portable elements registered + * @returns {Object} - all portable elements and their versions + */ + getAllVersions: function getAllVersions() { + var all = {}; + _.forIn(this._registry, function(versions, id) { + all[id] = _.map(versions, 'version'); + }); + return all; + }, + + /** + * Get the runtime for a given portable element + * @param {String} typeIdentifier + * @param {String} [version] - if the version is provided, the method will try to find that version + * @returns {Object} the runtime model + */ + getRuntime: function getRuntime(typeIdentifier, version) { + var portableElement = this.get(typeIdentifier, version); + if (portableElement) { + return _.assign(portableElement.runtime, { + id: portableElement.typeIdentifier, + label: portableElement.label, + baseUrl: portableElement.baseUrl, + model: portableElement.model + }); + } else { + this.trigger('error', { + message: 'no portable element runtime found', + typeIdentifier: typeIdentifier, + version: version + }); + } + }, + + /** + * Get the creator model for a given portable element + * @param {String} typeIdentifier + * @param {String} [version] - if the version is provided, the method will try to find that version + * @returns {Object} the creator model + */ + getCreator: function getCreator(typeIdentifier, version) { + var portableElement = this.get(typeIdentifier, version); + if (portableElement && portableElement.creator) { + return _.assign(portableElement.creator, { + id: portableElement.typeIdentifier, + label: portableElement.label, + baseUrl: portableElement.baseUrl, + response: portableElement.response, + model: portableElement.model, + xmlns: portableElement.xmlns + }); + } else { + this.trigger('error', { + message: 'no portable element runtime found', + typeIdentifier: typeIdentifier, + version: version + }); + } + }, + + /** + * Returned all enabled created from the registry + * @returns {Object} the collection of creators + */ + getLatestCreators: function getLatestCreators() { + var all = {}; + _.forIn(this._registry, function(versions, id) { + var lastVersion = _.last(versions); + + //check if the portable element is creatable: + if (lastVersion.creator && lastVersion.creator.hook && lastVersion.enabled) { + all[id] = lastVersion; + } + }); + return all; + }, + + /** + * Get the baseUrl for a given portable element + * @param {String} typeIdentifier + * @param {String} [version] - if the version is provided, the method will try to find that version + * @returns {String} the base url + */ + getBaseUrl: function getBaseUrl(typeIdentifier, version) { + var runtime = this.get(typeIdentifier, version); + if (runtime) { + return runtime.baseUrl; + } + return ''; + }, + + /** + * Load the runtimes from registered portable element provider(s) + * + * @param {Object} [options] + * @param {Array} [options.include] - the exact list of portable element typeIdentifier that should be loaded + * @param {Boolean} [options.reload] - tells if all interactions should be reloaded + * @returns {Promise} + */ + loadRuntimes: function loadRuntimes(options) { + var self = this; + var loadPromise; + + options = _.defaults(options || {}, _defaultLoadingOptions); + + loadPromise = self.loadProviders(options).then(function(providers) { + var loadStack = []; + + _.forEach(providers, function(provider) { + if (provider) { + //check that the provider is loaded + loadStack.push(provider.load()); + } + }); + + //performs the loadings in parallel + return Promise.all(loadStack).then(function(results) { + //TODO could remove one level of promise + + var configLoadingStack = []; + + //update registry + self._registry = _.reduce( + results, + function(acc, _pcis) { + return _.merge(acc, _pcis); + }, + self._registry || {} + ); //incremental loading + + //pre-configuring the baseUrl of the portable element's source + _.forIn(self._registry, function(versions, typeIdentifier) { + if (_.isArray(options.include) && _.indexOf(options.include, typeIdentifier) < 0) { + return true; + } + configLoadingStack.push(loadModuleConfig(self.get(typeIdentifier))); + }); + + return Promise.all(configLoadingStack).then(function(moduleConfigs) { + var requireConfigAliases = _.reduce( + moduleConfigs, + function(acc, paths) { + return _.merge(acc, paths); + }, + {} + ); + + //save the required libs name => path to global require alias + //TODO in future planned user story : change this to a local require context to solve conflicts in third party module naming + _requirejs.config({ paths: requireConfigAliases }); + }); + }); + }); + + loadPromise + .then(function() { + self.trigger('runtimesloaded'); + }) + .catch(function(err) { + self.trigger('error', err); + }); + + return loadPromise; + }, + + /** + * Load the creators from registered portable element provider(s) + * + * @param {Object} [options] + * @param {Array} [options.include] - the exact list of portable element typeIdentifier that should be loaded + * @param {Boolean} [options.reload] - tells if all interactions should be reloaded + * @returns {Promise} + */ + loadCreators: function loadCreators(options) { + var loadPromise; + var self = this; + + options = _.defaults(options || {}, _defaultLoadingOptions); + + loadPromise = self.loadRuntimes(options).then(function() { + var requiredCreatorHooks = []; + + _.forIn(self._registry, function(versions, typeIdentifier) { + var portableElementModel = self.get(typeIdentifier); //currently use the latest version only + if (portableElementModel.creator && portableElementModel.creator.hook) { + if (portableElementModel.enabled) { + if (_.isArray(options.include) && _.indexOf(options.include, typeIdentifier) < 0) { + return true; + } + } else { + if (!_.isArray(options.include) || _.indexOf(options.include, typeIdentifier) < 0) { + return true; + } + } + requiredCreatorHooks.push(portableElementModel.creator.hook.replace(/\.js$/, '')); + } + }); + + if (requiredCreatorHooks.length) { + return new Promise(function(resolve, reject) { + //@todo support caching? + _requirejs( + requiredCreatorHooks, + function() { + var creators = {}; + _.each(arguments, function(creatorHook) { + var id = creatorHook.getTypeIdentifier(); + var portableElementModel = self.get(id); + var i = _.findIndex(self._registry[id], { + version: portableElementModel.version + }); + if (i < 0) { + self.trigger( + 'error', + 'no creator found for id/version ' + + id + + '/' + + portableElementModel.version + ); + } else { + self._registry[id][i].creator.module = creatorHook; + creators[id] = creatorHook; + } + }); + resolve(creators); + }, + reject + ); + }); + } else { + return Promise.resolve({}); + } + }); + + loadPromise + .then(function(creators) { + self.trigger('creatorsloaded', creators); + return creators; + }) + .catch(function(err) { + self.trigger('error', err); + }); + + return loadPromise; + }, + + /** + * enable a portable element + * @param {String} typeIdentifier + * @param {String} [version] - if the version is provided, the method will try to find that version + * @returns {portableElementRegistry} + */ + enable: function enable(typeIdentifier, version) { + var portableElement = this.get(typeIdentifier, version); + if (portableElement) { + portableElement.enabled = true; + } + return this; + }, + + /** + * disable a portable element + * @param {String} typeIdentifier + * @param {String} [version] - if the version is provided, the method will try to find that version + * @returns {portableElementRegistry} + */ + disable: function disable(typeIdentifier, version) { + var portableElement = this.get(typeIdentifier, version); + if (portableElement) { + portableElement.enabled = false; + } + return this; + }, + + /** + * check is a portable element is enabled + * @param {String} typeIdentifier + * @param {String} [version] - if the version is provided, the method will try to find that version + * @returns {portableElementRegistry} + */ + isEnabled: function isEnabled(typeIdentifier, version) { + var portableElement = this.get(typeIdentifier, version); + return portableElement && portableElement.enabled === true; + } + }) + ); +} diff --git a/src/portableElementRegistry/factory/icRegistry.js b/src/portableElementRegistry/factory/icRegistry.js new file mode 100644 index 00000000..028b2041 --- /dev/null +++ b/src/portableElementRegistry/factory/icRegistry.js @@ -0,0 +1,29 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2016 (original work) Open Assessment Technologies SA; + * + */ + +import portableElementRegistry from 'taoQtiItem/portableElementRegistry/factory/factory'; + +/** + * Info control registry has currently no additonal fonctionalities than the basic portable element registry + * + * @returns {Object} registry instance + */ +export default function infoControlRegistry() { + return portableElementRegistry(); +} diff --git a/src/portableElementRegistry/icRegistry.js b/src/portableElementRegistry/icRegistry.js new file mode 100644 index 00000000..3c936191 --- /dev/null +++ b/src/portableElementRegistry/icRegistry.js @@ -0,0 +1,38 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2016 (original work) Open Assessment Technologies SA; + * + */ +import _ from 'lodash'; +import icRegistry from 'taoQtiItem/portableElementRegistry/factory/icRegistry'; +import module from 'module'; + +//create a preregistered singleton of icRegistry +var registry = icRegistry(); +var providers = []; +var config = module.config(); + +if (config && config.providers) { + providers = config.providers; +} + +_.each(providers, function(provider) { + if (provider.name && provider.module) { + registry.registerProvider(provider.module); + } +}); + +export default registry; diff --git a/src/portableElementRegistry/provider/localManifestProvider.js b/src/portableElementRegistry/provider/localManifestProvider.js new file mode 100644 index 00000000..886285e3 --- /dev/null +++ b/src/portableElementRegistry/provider/localManifestProvider.js @@ -0,0 +1,157 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2016 (original work) Open Assessment Technologies SA; + * + */ +import _ from 'lodash'; + +var _portableElementManifests = {}; +var _registry = {}; +var _moduleName = 'taoQtiItem/portableElementRegistry/provider/localManifestProvider'; + +/** + * Recursively set the prefix of portable element path + * e.g. transform a relative local path "./runtime/entrypoint.js" to "prefix/runtime/entrypoint.js" + * + * @param {Object|String|Array} obj + * @param {String} prefix + * @returns {Object|String|Array} + */ +function setPortableElementPrefix(obj, prefix) { + var ret; + if (_.isArray(obj)) { + ret = _.map(obj, function(v) { + return setPortableElementPrefix(v, prefix); + }); + } else if (_.isPlainObject(obj)) { + ret = {}; + _.forIn(obj, function(v, k) { + ret[k] = setPortableElementPrefix(v, prefix); + }); + } else if (_.isString(obj)) { + ret = obj.replace('./', prefix + '/'); + } + return ret; +} + +/** + * Use portable element source if exists + * + * @param {Object} manifest - the manifest of the pci to be modified + * @returns {Object} the modified manifest + */ +function useSource(manifest) { + var runtimeModules, runtimeSrc, typeIdentifier; + + // Make sure that we use the unbundled runtime + if (manifest.model === 'IMSPCI') { + runtimeModules = (manifest.runtime || {}).modules; + runtimeSrc = (manifest.runtime || {}).src || []; + + // in case of a TAO bundled PCI (= we have a "src" entry), + // we redirect the module to the entry point of the PCI instead of its minified version + if (runtimeSrc.length) { + _.forOwn(runtimeModules, function(allModulesFiles, moduleKey) { + if (moduleKey.indexOf('.min') === moduleKey.length - '.min'.length) { + runtimeModules[moduleKey] = allModulesFiles.map(function(filePath) { + return filePath.replace('.min.js', '.js'); + }); + } + }); + } + } else { + if (manifest.runtime && _.isArray(manifest.runtime.src)) { + delete manifest.runtime.hook; //hook is going to be removed with the support of IMS PCI v1 + manifest.runtime.libraries = manifest.runtime.src; + } + if (manifest.creator && _.isArray(manifest.creator.src)) { + delete manifest.creator.hook; //hook is going to be removed with the support of IMS PCI v1 + manifest.creator.libraries = manifest.creator.src; + } + } + return manifest; +} + +/** + * Generic portable element provider than loads portable elements from their manifest. + * It is useful for testing if the portable element source location is easily accessible. + * + * Sample usage : + * + * localManifestProvider.addManifestPath('pci_A', 'qtiItemPci/pciCreator/dev/pci_A/pciCreator.json'); + * localManifestProvider.addManifestPath('pci_B', 'taoExtB/pciCreator/dev/pci_B/pciCreator.json'); + * localManifestProvider.addManifestPath('pci_C', 'qtiSamples/some/path/pci_C/pciCreator.json'); + * ciRegistry.registerProvider(pciTestProvider.getModuleName()); + * + */ +export default { + /** + * Add testing + * @param id + * @param pathToManifest + */ + addManifestPath: function addManifestPath(id, pathToManifest) { + _portableElementManifests[id] = pathToManifest; + return this; + }, + /** + * Get the amd module name + * @returns {string} + */ + getModuleName: function getModuleName() { + return _moduleName; + }, + /** + * Implementation of the mandatory method load() of a portable element provider + * + * @returns {Promise} resolved when the added pci registered through their manifest are loaded + */ + load: function load() { + return new Promise(function(resolve, reject) { + var _requiredManifests = _.map(_portableElementManifests, function(manifest) { + return 'json!' + manifest; + }); + require(_requiredManifests, function() { + var ok = true; + _.each([].slice.call(arguments), function(manifest) { + var id; + if (manifest && manifest.typeIdentifier) { + id = manifest.typeIdentifier; + if (!_portableElementManifests[id]) { + reject('typeIdentifier mismatch'); + ok = false; + return false; + } + + manifest = useSource(manifest); + + manifest.baseUrl = + window.location.origin + + '/' + + _portableElementManifests[id].replace( + /^([a-zA-Z]*)\/(.*)\/([a-zA-Z]*)(Creator.json$)/, + '$1/views/js/$2' + ); + _registry[id] = [setPortableElementPrefix(manifest, id)]; + } + }); + if (ok) { + resolve(_registry); + } + }, reject); + }); + } +}; diff --git a/src/portableElementRegistry/provider/sideLoadingProviderFactory.js b/src/portableElementRegistry/provider/sideLoadingProviderFactory.js new file mode 100644 index 00000000..f097c5c6 --- /dev/null +++ b/src/portableElementRegistry/provider/sideLoadingProviderFactory.js @@ -0,0 +1,50 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2018 (original work) Open Assessment Technologies SA; + * + */ + +/** + * Generic portable element provider than loads portable elements directly on the client side + * + * Sample usage : + * var pciProvider = sideLoadingProviderFactory(pciManifestArray); + * pciProvider.add('anotherPciXYZ', anotherPciXYZObject); + * + */ +export default function sideLoadingProviderFactory(portableElements) { + var _registry = portableElements; + + return { + /** + * Side load the portable element here + * @param id + * @param pathToManifest + */ + add: function add(id, portableElement) { + _registry[id] = portableElement; + return this; + }, + /** + * Implementation of the mandatory method load() of a portable element provider + * + * @returns {Promise} resolved when the added pci registered through their manifest are loaded + */ + load: function load() { + return _registry; + } + }; +} diff --git a/src/qtiCommonRenderer/renderers/Img.js b/src/qtiCommonRenderer/renderers/Img.js index b09837c5..23f3a837 100644 --- a/src/qtiCommonRenderer/renderers/Img.js +++ b/src/qtiCommonRenderer/renderers/Img.js @@ -15,7 +15,7 @@ * * Copyright (c) 2014 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); */ -import Promise from 'core/promise'; + import 'ui/waitForMedia'; import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/img'; import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; diff --git a/src/qtiCommonRenderer/renderers/Math.js b/src/qtiCommonRenderer/renderers/Math.js index e972c3e9..eb106924 100644 --- a/src/qtiCommonRenderer/renderers/Math.js +++ b/src/qtiCommonRenderer/renderers/Math.js @@ -24,7 +24,7 @@ * @author Bertrand Chevrier */ import _ from 'lodash'; -import Promise from 'core/promise'; + import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/math'; import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; import MathJax from 'mathJax'; diff --git a/src/qtiCommonRenderer/renderers/PortableInfoControl.js b/src/qtiCommonRenderer/renderers/PortableInfoControl.js index 417660cd..c845546c 100644 --- a/src/qtiCommonRenderer/renderers/PortableInfoControl.js +++ b/src/qtiCommonRenderer/renderers/PortableInfoControl.js @@ -21,7 +21,7 @@ * Portable Info Control Common Renderer */ import _ from 'lodash'; -import Promise from 'core/promise'; + import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/infoControl'; import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; import PortableElement from 'taoQtiItem/qtiCommonRenderer/helpers/PortableElement'; diff --git a/src/qtiCommonRenderer/renderers/interactions/AssociateInteraction.js b/src/qtiCommonRenderer/renderers/interactions/AssociateInteraction.js index 7ca51c4b..bb8eb893 100644 --- a/src/qtiCommonRenderer/renderers/interactions/AssociateInteraction.js +++ b/src/qtiCommonRenderer/renderers/interactions/AssociateInteraction.js @@ -24,7 +24,7 @@ import $ from 'jquery'; import _ from 'lodash'; import __ from 'i18n'; -import Promise from 'core/promise'; + import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/associateInteraction'; import pairTpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/associateInteraction.pair'; import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; diff --git a/src/qtiCommonRenderer/renderers/interactions/ExtendedTextInteraction.js b/src/qtiCommonRenderer/renderers/interactions/ExtendedTextInteraction.js index 13ff8cf9..fd4bcd45 100644 --- a/src/qtiCommonRenderer/renderers/interactions/ExtendedTextInteraction.js +++ b/src/qtiCommonRenderer/renderers/interactions/ExtendedTextInteraction.js @@ -24,7 +24,7 @@ import $ from 'jquery'; import _ from 'lodash'; import __ from 'i18n'; -import Promise from 'core/promise'; + import strLimiter from 'util/strLimiter'; import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/extendedTextInteraction'; import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; diff --git a/src/qtiCommonRenderer/renderers/interactions/GraphicAssociateInteraction.js b/src/qtiCommonRenderer/renderers/interactions/GraphicAssociateInteraction.js index 3c5aad3a..6289d6db 100644 --- a/src/qtiCommonRenderer/renderers/interactions/GraphicAssociateInteraction.js +++ b/src/qtiCommonRenderer/renderers/interactions/GraphicAssociateInteraction.js @@ -23,7 +23,7 @@ import $ from 'jquery'; import _ from 'lodash'; import __ from 'i18n'; -import Promise from 'core/promise'; + import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/graphicAssociateInteraction'; import graphic from 'taoQtiItem/qtiCommonRenderer/helpers/Graphic'; import pciResponse from 'taoQtiItem/qtiCommonRenderer/helpers/PciResponse'; diff --git a/src/qtiCommonRenderer/renderers/interactions/GraphicGapMatchInteraction.js b/src/qtiCommonRenderer/renderers/interactions/GraphicGapMatchInteraction.js index 866fb36f..8723895f 100644 --- a/src/qtiCommonRenderer/renderers/interactions/GraphicGapMatchInteraction.js +++ b/src/qtiCommonRenderer/renderers/interactions/GraphicGapMatchInteraction.js @@ -24,7 +24,7 @@ import $ from 'jquery'; import _ from 'lodash'; import __ from 'i18n'; import module from 'module'; -import Promise from 'core/promise'; + import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/graphicGapMatchInteraction'; import graphic from 'taoQtiItem/qtiCommonRenderer/helpers/Graphic'; import pciResponse from 'taoQtiItem/qtiCommonRenderer/helpers/PciResponse'; diff --git a/src/qtiCommonRenderer/renderers/interactions/GraphicOrderInteraction.js b/src/qtiCommonRenderer/renderers/interactions/GraphicOrderInteraction.js index ef1d886c..c2a5cae6 100644 --- a/src/qtiCommonRenderer/renderers/interactions/GraphicOrderInteraction.js +++ b/src/qtiCommonRenderer/renderers/interactions/GraphicOrderInteraction.js @@ -23,7 +23,7 @@ import $ from 'jquery'; import _ from 'lodash'; import __ from 'i18n'; -import Promise from 'core/promise'; + import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/graphicOrderInteraction'; import graphic from 'taoQtiItem/qtiCommonRenderer/helpers/Graphic'; import pciResponse from 'taoQtiItem/qtiCommonRenderer/helpers/PciResponse'; diff --git a/src/qtiCommonRenderer/renderers/interactions/HotspotInteraction.js b/src/qtiCommonRenderer/renderers/interactions/HotspotInteraction.js index 07050a10..f2796c48 100644 --- a/src/qtiCommonRenderer/renderers/interactions/HotspotInteraction.js +++ b/src/qtiCommonRenderer/renderers/interactions/HotspotInteraction.js @@ -23,7 +23,7 @@ import $ from 'jquery'; import _ from 'lodash'; import __ from 'i18n'; -import Promise from 'core/promise'; + import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/hotspotInteraction'; import graphic from 'taoQtiItem/qtiCommonRenderer/helpers/Graphic'; import pciResponse from 'taoQtiItem/qtiCommonRenderer/helpers/PciResponse'; diff --git a/src/qtiCommonRenderer/renderers/interactions/MediaInteraction.js b/src/qtiCommonRenderer/renderers/interactions/MediaInteraction.js index f25f6a0c..eaf83233 100644 --- a/src/qtiCommonRenderer/renderers/interactions/MediaInteraction.js +++ b/src/qtiCommonRenderer/renderers/interactions/MediaInteraction.js @@ -24,7 +24,7 @@ */ import $ from 'jquery'; import _ from 'lodash'; -import Promise from 'core/promise'; + import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/mediaInteraction'; import pciResponse from 'taoQtiItem/qtiCommonRenderer/helpers/PciResponse'; import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; diff --git a/src/qtiCommonRenderer/renderers/interactions/PortableCustomInteraction.js b/src/qtiCommonRenderer/renderers/interactions/PortableCustomInteraction.js index 3fc2935e..dfd24ac2 100644 --- a/src/qtiCommonRenderer/renderers/interactions/PortableCustomInteraction.js +++ b/src/qtiCommonRenderer/renderers/interactions/PortableCustomInteraction.js @@ -22,7 +22,7 @@ * @author Bertrand Chevrier */ import _ from 'lodash'; -import Promise from 'core/promise'; + import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/customInteraction'; import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; import PortableElement from 'taoQtiItem/qtiCommonRenderer/helpers/PortableElement'; diff --git a/src/qtiCommonRenderer/renderers/interactions/SelectPointInteraction.js b/src/qtiCommonRenderer/renderers/interactions/SelectPointInteraction.js index 8ae040c7..5c16531e 100644 --- a/src/qtiCommonRenderer/renderers/interactions/SelectPointInteraction.js +++ b/src/qtiCommonRenderer/renderers/interactions/SelectPointInteraction.js @@ -24,7 +24,7 @@ */ import $ from 'jquery'; import _ from 'lodash'; -import Promise from 'core/promise'; + import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/selectPointInteraction'; import graphic from 'taoQtiItem/qtiCommonRenderer/helpers/Graphic'; import pciResponse from 'taoQtiItem/qtiCommonRenderer/helpers/PciResponse'; diff --git a/src/qtiRunner/core/QtiRunner.js b/src/qtiRunner/core/QtiRunner.js new file mode 100755 index 00000000..f6c5e723 --- /dev/null +++ b/src/qtiRunner/core/QtiRunner.js @@ -0,0 +1,314 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2013 (original work) Open Assessment Techonologies SA (under the project TAO-PRODUCT); + * + * + */ +/** + * A class to regroup QTI functionalities + * + * @author CRP Henri Tudor - TAO Team - {@link http://www.tao.lu} + * @license GPLv2 http://www.opensource.org/licenses/gpl-2.0.php + * @package taoItems + * @requires jquery {@link http://www.jquery.com} + */ +import $ from 'jquery'; +import _ from 'lodash'; +import context from 'context'; +import module from 'module'; + +import iframeNotifier from 'iframeNotifier'; +import ItemLoader from 'taoQtiItem/qtiItem/core/Loader'; +import modalFeedbackInline from 'taoQtiItem/qtiRunner/modalFeedback/inlineRenderer'; +import modalFeedbackModal from 'taoQtiItem/qtiRunner/modalFeedback/modalRenderer'; + +var timeout = (context.timeout > 0 ? context.timeout + 1 : 30) * 1000; + +var QtiRunner = function() { + this.item = null; + this.rpEngine = null; + this.renderer = null; + this.loader = null; + this.itemApi = undefined; +}; + +QtiRunner.prototype.updateItemApi = function() { + var responses = this.getResponses(); + var states = this.getStates(); + var variables = []; + // Transform responses into state variables. + for (var key in states) { + var value = states[key]; + // This is where we set variables that will be collected and stored + // as the Item State. We do not want to store large files into + // the state, and force the client to download these files + // all over again. We then transform them as a place holder, that will + // simply indicate that a file composes the state. + + if (value.response && typeof value.response.base !== 'undefined') { + for (var property in value.response.base) { + if (property === 'file') { + var file = value.response.base.file; + // QTI File found! Replace it with an appropriate placeholder. + // The data is base64('qti_file_datatype_placeholder_data') + value.response = { + base: { + file: { + name: file.name, + mime: 'qti+application/octet-stream', + data: 'cXRpX2ZpbGVfZGF0YXR5cGVfcGxhY2Vob2xkZXJfZGF0YQ==' + } + } + }; + } + } + } + + variables[key] = value; + } + + //set all variables at once + this.itemApi.setVariables(variables); + + // Save the responses that will be used for response processing. + this.itemApi.saveResponses(responses); + this.itemApi.resultApi.setQtiRunner(this); +}; + +QtiRunner.prototype.setItemApi = function(itemApi) { + this.itemApi = itemApi; + var that = this; + var oldStateVariables = JSON.stringify(itemApi.stateVariables); + + itemApi.onKill(function(killCallback) { + // If the responses did not change, + // just close gracefully. + + // Collect new responses and update item API. + that.updateItemApi(); + var newStateVariables = JSON.stringify(itemApi.stateVariables); + + // Store the results. + if (oldStateVariables !== newStateVariables || itemApi.serviceApi.getHasBeenPaused()) { + itemApi.submit(function() { + // Send successful signal. + itemApi.serviceApi.setHasBeenPaused(false); + killCallback(0); + }); + } else { + killCallback(0); + } + }); +}; + +QtiRunner.prototype.setRenderer = function(renderer) { + if (renderer.isRenderer) { + this.renderer = renderer; + } else { + throw 'invalid renderer'; + } +}; + +QtiRunner.prototype.getLoader = function() { + if (!this.loader) { + this.loader = new ItemLoader(); + } + return this.loader; +}; + +QtiRunner.prototype.loadItemData = function(data, callback) { + var self = this; + this.getLoader().loadItemData(data, function(item) { + self.item = item; + callback(self.item); + }); +}; + +QtiRunner.prototype.loadElements = function(elements, callback) { + if (this.getLoader().item) { + this.getLoader().loadElements(elements, callback); + } else { + throw 'QtiRunner : cannot load elements in empty item'; + } +}; + +QtiRunner.prototype.renderItem = function(data, done) { + var self = this; + + done = _.isFunction(done) ? done : _.noop; + + var render = function() { + if (!self.item) { + throw 'cannot render item: empty item'; + } + if (self.renderer) { + self.renderer.load(function() { + self.item.setRenderer(self.renderer); + self.item.render({}, $('#qti_item')); + + // Race between postRendering and timeout + // postRendering waits for everything to be resolved or one reject + Promise.race([ + Promise.all(self.item.postRender()), + new Promise(function(resolve, reject) { + _.delay(reject, timeout, new Error('Post rendering ran out of time.')); + }) + ]) + .then(function() { + self.item.getContainer().on('responseChange', function(e, data) { + if (data.interaction && data.interaction.attr('responseIdentifier') && data.response) { + iframeNotifier.parent('responsechange', [ + data.interaction.attr('responseIdentifier'), + data.response + ]); + } + }); + + self.initInteractionsResponse(); + self.listenForThemeChange(); + done(); + }) + .catch(function(err) { + //in case of postRendering issue, we are also done + done(); + + throw new Error('Error in post rendering : ' + err); + }); + }, self.getLoader().getLoadedClasses()); + } else { + throw 'cannot render item: no rendered set'; + } + }; + + if (typeof data === 'object') { + this.loadItemData(data, render); + } else { + render(); + } +}; + +QtiRunner.prototype.initInteractionsResponse = function() { + var self = this; + if (self.item) { + var interactions = self.item.getInteractions(); + for (var i in interactions) { + var interaction = interactions[i]; + var responseId = interaction.attr('responseIdentifier'); + self.itemApi.getVariable(responseId, function(values) { + if (values) { + interaction.setState(values); + iframeNotifier.parent('stateready', [responseId, values]); + } else { + var states = self.getStates(); + if (_.indexOf(states, responseId)) { + self.itemApi.setVariable(responseId, states[responseId]); + interaction.setState(states[responseId]); + iframeNotifier.parent('stateready', [responseId, states[responseId]]); + } + } + }); + } + } +}; + +/** + * If an event 'themechange' bubbles to "#qti_item" node + * then we tell the renderer to switch the theme. + */ +QtiRunner.prototype.listenForThemeChange = function listenForThemeChange() { + var self = this; + var $container = this.renderer.getContainer(this.item); + if (!$container.length) { + $container = $('.qti-item'); + } + $container.off('themechange').on('themechange', function(e, themeName) { + var themeLoader = self.renderer.getThemeLoader(); + themeName = themeName || e.originalEvent.detail; + if (themeLoader) { + themeLoader.change(themeName); + } + }); +}; + +QtiRunner.prototype.validate = function() { + this.updateItemApi(); + this.itemApi.finish(); +}; + +QtiRunner.prototype.getResponses = function() { + var responses = {}; + var interactions = this.item.getInteractions(); + + _.forEach(interactions, function(interaction) { + var response = {}; + try { + response = interaction.getResponse(); + } catch (e) { + console.error(e); + } + responses[interaction.attr('responseIdentifier')] = response; + }); + + return responses; +}; + +QtiRunner.prototype.getStates = function() { + var states = {}; + var interactions = this.item.getInteractions(); + + _.forEach(interactions, function(interaction) { + var state = {}; + try { + state = interaction.getState(); + } catch (e) { + console.error(e); + } + states[interaction.attr('responseIdentifier')] = state; + }); + + return states; +}; + +QtiRunner.prototype.setResponseProcessing = function(callback) { + this.rpEngine = callback; +}; + +QtiRunner.prototype.showFeedbacks = function(itemSession, callback, onShowCallback) { + var inlineDisplay = !!module.config().inlineModalFeedback; + + //currently only modal feedbacks are available + if (inlineDisplay) { + return modalFeedbackInline.showFeedbacks( + this.item, + this.getLoader(), + this.renderer, + itemSession, + callback, + onShowCallback + ); + } else { + return modalFeedbackModal.showFeedbacks( + this.item, + this.getLoader(), + this.renderer, + itemSession, + callback, + onShowCallback + ); + } +}; + +export default QtiRunner; diff --git a/src/qtiRunner/core/Renderer.js b/src/qtiRunner/core/Renderer.js new file mode 100755 index 00000000..e1132732 --- /dev/null +++ b/src/qtiRunner/core/Renderer.js @@ -0,0 +1,772 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2014 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); + * + */ + +/** + * A factory to create a QTI renderer. + * + * @author Sam Sipasseuth + * @author Bertrand Chevrier + */ +import _ from 'lodash'; +import $ from 'jquery'; +import Handlebars from 'handlebars'; +import Element from 'taoQtiItem/qtiItem/core/Element'; +import interactionHelper from 'taoQtiItem/qtiItem/helper/interactionHelper'; +import themeLoader from 'ui/themeLoader'; +import themesHelper from 'ui/themes'; + +var _isValidRenderer = function(renderer) { + var valid = true; + + if (typeof renderer !== 'object') { + return false; + } + + var classCorrect = false; + if (renderer.qtiClass) { + if (_.indexOf(_renderableClasses, renderer.qtiClass) >= 0) { + classCorrect = true; + } else { + var pos = renderer.qtiClass.indexOf('.'); + if (pos > 0) { + var qtiClass = renderer.qtiClass.slice(0, pos); + var subClass = renderer.qtiClass.slice(pos + 1); + if (_renderableSubclasses[qtiClass] && _.indexOf(_renderableSubclasses[qtiClass], subClass) >= 0) { + classCorrect = true; + } + } + } + } + if (!classCorrect) { + valid = false; + throw new Error('invalid qti class name in renderer declaration : ' + renderer.qtiClass); + } + + if (!renderer.template) { + valid = false; + throw new Error('missing template in renderer declaration : ' + renderer.qtiClass); + } + + return valid; +}; + +var _renderableClasses = [ + '_container', + 'assessmentItem', + 'stylesheet', + 'responseDeclaration', + 'outcomeDeclaration', + 'responseProcessing', + '_simpleFeedbackRule', + '_tooltip', + 'img', + 'math', + 'object', + 'table', + 'modalFeedback', + 'rubricBlock', + 'associateInteraction', + 'choiceInteraction', + 'extendedTextInteraction', + 'gapMatchInteraction', + 'graphicAssociateInteraction', + 'graphicGapMatchInteraction', + 'graphicOrderInteraction', + 'hotspotInteraction', + 'hottextInteraction', + 'inlineChoiceInteraction', + 'matchInteraction', + 'mediaInteraction', + 'orderInteraction', + 'selectPointInteraction', + 'sliderInteraction', + 'textEntryInteraction', + 'uploadInteraction', + 'endAttemptInteraction', + 'customInteraction', + 'prompt', + 'associableHotspot', + 'gap', + 'gapImg', + 'gapText', + 'hotspotChoice', + 'hottext', + 'inlineChoice', + 'simpleAssociableChoice', + 'simpleChoice', + 'infoControl', + 'include', + 'printedVariable' +]; + +/** + * The list of qti element dependencies. It is used internally to load dependent qti classes + */ +var _dependencies = { + assessmentItem: ['stylesheet', '_container', 'prompt', 'modalFeedback'], + rubricBlock: ['_container'], + associateInteraction: ['simpleAssociableChoice'], + choiceInteraction: ['simpleChoice'], + gapMatchInteraction: ['gap', 'gapText'], + graphicAssociateInteraction: ['associableHotspot'], + graphicGapMatchInteraction: ['associableHotspot', 'gapImg'], + graphicOrderInteraction: ['hotspotChoice'], + hotspotInteraction: ['hotspotChoice'], + hottextInteraction: ['hottext'], + inlineChoiceInteraction: ['inlineChoice'], + matchInteraction: ['simpleAssociableChoice'], + orderInteraction: ['simpleChoice'] +}; + +/** + * The list of supported qti subclasses. + */ +var _renderableSubclasses = { + simpleAssociableChoice: ['associateInteraction', 'matchInteraction'], + simpleChoice: ['choiceInteraction', 'orderInteraction'] +}; + +/** + * List of the default properties for the item + */ +var _defaults = { + shuffleChoices: true +}; + +/** + * Get the location of the document, useful to define a baseUrl for the required context + * @returns {String} + */ +function getDocumentBaseUrl() { + return window.location.protocol + '//' + window.location.host + window.location.pathname.replace(/([^\/]*)$/, ''); +} + +/** + * The built Renderer class + * @constructor + * @param {Object} [options] - the renderer options + * @param {AssetManager} [options.assetManager] - The renderer needs an AssetManager to resolve URLs (see {@link taoItems/assets/manager}) + * @param {Boolean} [options.shuffleChoices = true] - Does the renderer take care of the shuffling + * @param {Object} [options.decorators] - to set up rendering decorator + * @param {preRenderDecorator} [options.decorators.before] - to set up a pre decorator + * @param {postRenderDecorator} [options.decorators.after] - to set up a post decorator + */ +var Renderer = function(options) { + /** + * Store the registered renderer location + */ + var _locations = {}; + + /** + * Store loaded renderers + */ + var _renderers = {}; + + options = _.defaults(options || {}, _defaults); + + this.isRenderer = true; + + this.name = ''; + + //store shuffled choice here + this.shuffledChoices = []; + + /** + * Get the actual renderer of the give qti class or subclass: + * e.g. simplceChoice, simpleChoice.choiceInteraction, simpleChoice.orderInteraction + */ + var _getClassRenderer = function(qtiClass) { + var ret = null; + if (_renderers[qtiClass]) { + ret = _renderers[qtiClass]; + } else { + var pos = qtiClass.indexOf('.'); + if (pos > 0) { + qtiClass = qtiClass.slice(0, pos); + if (_renderers[qtiClass]) { + ret = _renderers[qtiClass]; + } + } + } + return ret; + }; + + /** + * Registers a QTI renderer class + * @param {String} qtiClass + * @param {Array} list + * @returns {Boolean} `true` if the class has been successfully registered + */ + function registerRendererClass(qtiClass, list) { + var success = false; + if (_locations[qtiClass] === false) { + //mark this class as not renderable + _renderers[qtiClass] = false; + success = true; + } else if (_locations[qtiClass]) { + list.push(_locations[qtiClass]); + success = true; + } + return success; + } + + /** + * Set the renderer options + * @param {String} key - the name of the option + * @param {*} value - the option vallue + * @returns {Renderer} for chaining + */ + this.setOption = function(key, value) { + if (typeof key === 'string') { + options[key] = value; + } + return this; + }; + + /** + * Set the renderer options + * @param {Object} opts - the options + * @returns {Renderer} for chaining + */ + this.setOptions = function(opts) { + options = _.extend(options, opts); + return this; + }; + + /** + * Get the renderer option + * @param {String} key - the name of the option + * @returns {*|null} the option vallue + */ + this.getOption = function(key) { + if (typeof key === 'string' && options[key]) { + return options[key]; + } + return null; + }; + + this.getCustomMessage = function getCustomMessage(elementName, messageKey) { + var messages = this.getOption('messages'); + if (messages && elementName && messages[elementName] && _.isString(messages[elementName][messageKey])) { + //currently not translatable but potentially could be if the need raises + return Handlebars.compile(messages[elementName][messageKey]); + } else { + return null; + } + }; + + /** + * Get the bound assetManager + * @returns {AssetManager} the assetManager + */ + this.getAssetManager = function getAssetManager() { + return options.assetManager; + }; + + /** + * Get the bound theme loader + * @returns {Object} the themeLoader + */ + this.getThemeLoader = function getThemeLoader() { + return this.themeLoader; + }; + + /** + * Renders the template + * @param {Object} element - the QTI model element + * @param {Object} data - the data to give to the template + * @param {String} [qtiSubclass] - to get the render of the element subclass (when element's qtiClass is abstract) + * @returns {String} the template results + * @throws {Error} if the renderer is not set or has no template bound + */ + this.renderTpl = function(element, data, qtiSubclass) { + var res; + var ret = ''; + var tplFound = false; + var qtiClass = qtiSubclass || element.qtiClass; + var renderer = _getClassRenderer(qtiClass); + var decorators = this.getOption('decorators'); + + if (!renderer || !_.isFunction(renderer.template)) { + throw new Error('no renderer template loaded under the class name : ' + qtiClass); + } + + //pre rendering decoration + if (_.isObject(decorators) && _.isFunction(decorators.before)) { + /** + * @callback preRenderDecoractor + * @param {Object} element - the QTI model element + * @param {String} [qtiSubclass] - to get the render of the element subclass (when element's qtiClass is abstract) + * @returns {String} the decorator result + */ + res = decorators.before(element, qtiSubclass); + if (_.isString(res)) { + ret += res; + } + } + + //render the template + ret += renderer.template(data); + + //post rendering decoration + if (_.isObject(decorators) && _.isFunction(decorators.after)) { + /** + * @callback postRenderDecoractor + * @param {Object} element - the QTI model element + * @param {String} [qtiSubclass] - to get the render of the element subclass (when element's qtiClass is abstract) + * @returns {String} the decorator result + */ + res = decorators.after(element, qtiSubclass); + if (_.isString(res)) { + ret += res; + } + } + return ret; + }; + + this.getData = function(element, data, qtiSubclass) { + var ret = data, + qtiClass = qtiSubclass || element.qtiClass, + renderer = _getClassRenderer(qtiClass); + + if (renderer) { + if (typeof renderer.getData === 'function') { + ret = renderer.getData.call(this, element, data); + } + } + + return ret; + }; + + this.renderDirect = function(tpl, data) { + return Handlebars.compile(tpl)(data); + }; + + this.getContainer = function(qtiElement, $scope, qtiSubclass) { + var ret = null, + qtiClass = qtiSubclass || qtiElement.qtiClass, + renderer = _getClassRenderer(qtiClass); + + if (renderer) { + ret = renderer.getContainer(qtiElement, $scope); + } else { + throw 'no renderer found for the class : ' + qtiElement.qtiClass; + } + return ret; + }; + + this.postRender = function(qtiElement, data, qtiSubclass) { + var qtiClass = qtiSubclass || qtiElement.qtiClass; + var renderer = _getClassRenderer(qtiClass); + + if (renderer && typeof renderer.render === 'function') { + return renderer.render.call(this, qtiElement, data); + } + }; + + this.setResponse = function(qtiInteraction, response, qtiSubclass) { + var ret = false, + qtiClass = qtiSubclass || qtiInteraction.qtiClass, + renderer = _getClassRenderer(qtiClass); + + if (renderer) { + if (typeof renderer.setResponse === 'function') { + ret = renderer.setResponse.call(this, qtiInteraction, response); + var $container = renderer.getContainer.call(this, qtiInteraction); + if ($container instanceof $ && $container.length) { + $container.trigger('responseSet', [qtiInteraction, response]); + } + } + } else { + throw 'no renderer registered under the name : ' + qtiClass; + } + return ret; + }; + + this.getResponse = function(qtiInteraction, qtiSubclass) { + var ret = false, + qtiClass = qtiSubclass || qtiInteraction.qtiClass, + renderer = _getClassRenderer(qtiClass); + + if (renderer) { + if (typeof renderer.getResponse === 'function') { + ret = renderer.getResponse.call(this, qtiInteraction); + } + } else { + throw 'no renderer registered under the name : ' + qtiClass; + } + return ret; + }; + + this.resetResponse = function(qtiInteraction, qtiSubclass) { + var ret = false, + qtiClass = qtiSubclass || qtiInteraction.qtiClass, + renderer = _getClassRenderer(qtiClass); + + if (renderer) { + if (typeof renderer.resetResponse === 'function') { + ret = renderer.resetResponse.call(this, qtiInteraction); + } + } else { + throw 'no renderer registered under the name : ' + qtiClass; + } + return ret; + }; + + /** + * Retrieve the state of the interaction. + * If the renderer has no state management, it falls back to the response management. + * + * @param {Object} qtiInteraction - the interaction + * @param {String} [qtiSubClass] - (not sure of the type and how it is used - Sam ? ) + * @returns {Object} the interaction's state + * + * @throws {Error} if no renderer is registered + */ + this.getState = function(qtiInteraction, qtiSubclass) { + var ret = false; + var qtiClass = qtiSubclass || qtiInteraction.qtiClass; + var renderer = _getClassRenderer(qtiClass); + + if (renderer) { + if (_.isFunction(renderer.getState)) { + ret = renderer.getState.call(this, qtiInteraction); + } else { + ret = renderer.getResponse.call(this, qtiInteraction); + } + } else { + throw 'no renderer registered under the name : ' + qtiClass; + } + return ret; + }; + + /** + * Retrieve the state of the interaction. + * If the renderer has no state management, it falls back to the response management. + * + * @param {Object} qtiInteraction - the interaction + * @param {Object} state - the interaction's state + * @param {String} [qtiSubClass] - (not sure of the type and how it is used - Sam ? ) + * + * @throws {Error} if no renderer is found + */ + this.setState = function(qtiInteraction, state, qtiSubclass) { + var qtiClass = qtiSubclass || qtiInteraction.qtiClass; + var renderer = _getClassRenderer(qtiClass); + + if (renderer) { + if (_.isFunction(renderer.setState)) { + renderer.setState.call(this, qtiInteraction, state); + } else { + renderer.resetResponse.call(this, qtiInteraction); + renderer.setResponse.call(this, qtiInteraction, state); + } + } else { + throw 'no renderer registered under the name : ' + qtiClass; + } + }; + + /** + * Calls the renderer destroy. + * Ask the renderer to run destroy if exists. + * + * @throws {Error} if no renderer is found + */ + this.destroy = function(qtiInteraction, qtiSubclass) { + var ret = false, + qtiClass = qtiSubclass || qtiInteraction.qtiClass, + renderer = _getClassRenderer(qtiClass); + + if (renderer) { + if (_.isFunction(renderer.destroy)) { + ret = renderer.destroy.call(this, qtiInteraction); + } + } else { + throw 'no renderer registered under the name : ' + qtiClass; + } + return ret; + }; + + this.getLoadedRenderers = function() { + return _renderers; + }; + + this.register = function(renderersLocations) { + _.extend(_locations, renderersLocations); + }; + + this.load = function(callback, requiredClasses) { + var self = this; + var required = []; + + var themeData = themesHelper.getCurrentThemeData(); + if (themeData) { + options.themes = themeData; + } + if (options.themes) { + //resolve themes paths + options.themes.base = this.resolveUrl(options.themes.base); + _.forEach(options.themes.available, function(theme, index) { + options.themes.available[index].path = self.resolveUrl(theme.path); + }); + this.themeLoader = themeLoader(options.themes).load(options.preload); + } + + if (requiredClasses) { + if (_.isArray(requiredClasses)) { + //exclude abstract classes + requiredClasses = _.intersection(requiredClasses, _renderableClasses); + + //add dependencies + _.each(requiredClasses, function(reqClass) { + var deps = _dependencies[reqClass]; + if (deps) { + requiredClasses = _.union(requiredClasses, deps); + } + }); + + _.forEach(requiredClasses, function(qtiClass) { + var requiredSubClasses; + if (_renderableSubclasses[qtiClass]) { + requiredSubClasses = _.intersection(requiredClasses, _renderableSubclasses[qtiClass]); + _.each(requiredSubClasses, function(subclass) { + if ( + !registerRendererClass(qtiClass + '.' + subclass, required) && + !registerRendererClass(qtiClass, required) + ) { + throw new Error( + self.name + + ' : missing qti class location declaration: ' + + qtiClass + + ', subclass: ' + + subclass + ); + } + }); + } else { + if (!registerRendererClass(qtiClass, required)) { + throw new Error(self.name + ' : missing qti class location declaration: ' + qtiClass); + } + } + }); + } else { + throw new Error('invalid argument type: expected array for arg "requireClasses"'); + } + } else { + required = _.values(_locations); + } + + require(required, function() { + _.each(arguments, function(clazz) { + if (_isValidRenderer(clazz)) { + _renderers[clazz.qtiClass] = clazz; + } + }); + + if (typeof callback === 'function') { + callback.call(self, _renderers); + } + }); + + return this; + }; + + /** + * Unload the renderer + */ + this.unload = function unload() { + if (this.themeLoader) { + themeLoader(options.themes).unload(); + } + return this; + }; + + /** + * Define the shuffling manually + * + * @param {Object} interaction - the interaction + * @param {Array} choices - the shuffled choices + * @param {String} identificationType - + */ + this.setShuffledChoices = function(interaction, choices, identificationType) { + if (Element.isA(interaction, 'interaction')) { + this.shuffledChoices[interaction.getSerial()] = _.pluck( + interactionHelper.findChoices(interaction, choices, identificationType), + 'serial' + ); + } + }; + + /** + * Get the choices shuffled for this interaction. + * + * @param {Object} interaction - the interaction + * @param {Boolean} reshuffle - by default choices are shuffled only once and store, but if true you can force shuffling again + * @param {String} returnedType - the choice type + * @returns {Array} the choices + */ + this.getShuffledChoices = function(interaction, reshuffle, returnedType) { + var choices = []; + var shuffled = []; + var serial, i; + + if (Element.isA(interaction, 'interaction')) { + serial = interaction.getSerial(); + + //1st time, we shuffle (or asked to force shuffling) + if (!this.shuffledChoices[serial] || reshuffle) { + if (Element.isA(interaction, 'matchInteraction')) { + this.shuffledChoices[serial] = []; + for (i = 0; i < 2; i++) { + choices[i] = interactionHelper.shuffleChoices(interaction.getChoices(i)); + this.shuffledChoices[serial][i] = _.pluck(choices[i], 'serial'); + } + } else { + choices = interactionHelper.shuffleChoices(interaction.getChoices()); + this.shuffledChoices[serial] = _.pluck(choices, 'serial'); + } + + //otherwise get the choices from the serials + } else { + if (Element.isA(interaction, 'matchInteraction')) { + _.forEach(choices, function(choice, index) { + if (index < 2) { + _.forEach(this.shuffledChoices[serial][i], function(choiceSerial) { + choice.push(interaction.getChoice(choiceSerial)); + }); + } + }); + } else { + _.forEach(this.shuffledChoices[serial], function(choiceSerial) { + choices.push(interaction.getChoice(choiceSerial)); + }); + } + } + + //do some type convertion + if (returnedType === 'serial' || returnedType === 'identifier') { + return interactionHelper.convertChoices(choices, returnedType); + } + + //pass value only, not ref + return _.clone(choices); + } + + return []; + }; + + this.getRenderers = function() { + return _renderers; + }; + + this.getLocations = function() { + return _locations; + }; + + /** + * Resolve URLs using the defined assetManager's strategies + * @param {String} url - the URL to resolve + * @returns {String} the resolved URL + */ + this.resolveUrl = function resolveUrl(url) { + if (!options.assetManager) { + return url; + } + if (typeof url === 'string' && url.length > 0) { + return options.assetManager.resolve(url); + } + }; + + /** + * @deprecated in favor of resolveUrl + */ + this.getAbsoluteUrl = function(relUrl) { + //let until method is removed + console.warn('DEPRECATED used getAbsoluteUrl with ', arguments); + + //allow relative url output only if explicitely said so + if (this.getOption('userRelativeUrl')) { + return relUrl.replace(/^\.?\//, ''); + } + + if (/^http(s)?:\/\//i.test(relUrl) || /^data:[^\/]+\/[^;]+(;charset=[\w]+)?;base64,/.test(relUrl)) { + //already absolute or base64 encoded + return relUrl; + } else { + var absUrl = ''; + var runtimeLocations = this.getOption('runtimeLocations'); + + if (runtimeLocations && _.size(runtimeLocations)) { + _.forIn(runtimeLocations, function(runtimeLocation, typeIdentifier) { + if (relUrl.indexOf(typeIdentifier) === 0) { + absUrl = relUrl.replace(typeIdentifier, runtimeLocation); + return false; //break + } + }); + } + + if (absUrl) { + return absUrl; + } else { + var baseUrl = this.getOption('baseUrl') || getDocumentBaseUrl(); + return baseUrl + relUrl; + } + } + }; + + this.setAreaBroker = function setAreaBroker(areaBroker) { + this._areaBroker = areaBroker; + }; + + this.getAreaBroker = function getAreaBroker() { + if (this._areaBroker) { + return this._areaBroker; + } + }; + + this.getItemCreator = function getItemCreator() { + return this.getOption('itemCreator'); + }; +}; + +/** + * Expose the renderer's factory + * @exports taoQtiItem/qtiRunner/core/Renderer + */ +export default { + /** + * Creates a new Renderer by extending the Renderer's prototype + * @param {Object} renderersLocations - + * @param {String} [name] - the new renderer name + * @param {Object} [defaultOptions] - the renderer options + */ + build: function(renderersLocations, name, defaultOptions) { + var NewRenderer = function() { + var options = _.isPlainObject(arguments[0]) ? arguments[0] : {}; + + Renderer.apply(this); + + this.register(renderersLocations); + this.name = name || ''; + this.setOptions(_.defaults(options, defaultOptions || {})); + }; + NewRenderer.prototype = Renderer.prototype; + return NewRenderer; + } +}; diff --git a/src/qtiRunner/modalFeedback/inlineRenderer.js b/src/qtiRunner/modalFeedback/inlineRenderer.js new file mode 100644 index 00000000..190bdc9d --- /dev/null +++ b/src/qtiRunner/modalFeedback/inlineRenderer.js @@ -0,0 +1,430 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2015 (original work) Open Assessment Techonologies SA; + * + */ +import _ from 'lodash'; +import $ from 'jquery'; +import context from 'context'; +import pci from 'taoQtiItem/qtiItem/helper/pci'; +import containerHelper from 'taoQtiItem/qtiItem/helper/container'; +import previewOkBtn from 'taoQtiItem/qtiRunner/tpl/inlineModalFeedbackPreviewButton'; +import deliveryOkBtn from 'taoQtiItem/qtiRunner/tpl/inlineModalFeedbackDeliveryButton'; +import iframeNotifier from 'iframeNotifier'; + +var timeout = (context.timeout > 0 ? context.timeout + 1 : 30) * 1000; + +/** + * Main function for the module. It loads and render the feedbacks accodring to the given itemSession variables + * + * @param {Object} item - the standard tao qti item object + * @param {Object} loader - the item loader instance + * @param {Object} renderer - the item render instance + * @param {Object} itemSession - session information containing the list of feedbacks to display + * @param {Function} onCloseCallback - the callback to be executed when the feedbacks are closed + * @param {Function} [onShowCallback] - the callback to be executed when the feedbacks are shown + * @returns {Number} Number of feedbacks to be displayed + */ +function showFeedbacks(item, loader, renderer, itemSession, onCloseCallback, onShowCallback) { + var interactionsDisplayInfo = getInteractionsDisplayInfo(item); + var messages = {}; + var renderedFeebacks = []; + var renderingQueue = []; + var $itemContainer = item.getContainer(); + var $itemBody = $itemContainer.children('.qti-itemBody'); + var firstFeedback; + + _.each(item.modalFeedbacks, function(feedback) { + var feedbackIds, message, $container, comparedOutcome, _currentMessageGroupId, interactionInfo; + var outcomeIdentifier = feedback.attr('outcomeIdentifier'); + var order = -1; + + //verify if the feedback should be displayed + if (itemSession[outcomeIdentifier]) { + //is the feedback in the list of feedbacks to be displayed ? + feedbackIds = pci.getRawValues(itemSession[outcomeIdentifier]); + if (_.indexOf(feedbackIds, feedback.id()) === -1) { + return true; //continue with next feedback + } + + //which group of feedbacks (interaction related) the feedback belongs to ? + message = getFeedbackMessageSignature(feedback); + comparedOutcome = containerHelper.getEncodedData(feedback, 'relatedOutcome'); + interactionInfo = _.find(interactionsDisplayInfo, { responseIdentifier: comparedOutcome }); + if (comparedOutcome && interactionInfo) { + $container = interactionInfo.displayContainer; + _currentMessageGroupId = interactionInfo.messageGroupId; + order = interactionInfo.order; + } else { + $container = $itemBody; + _currentMessageGroupId = '__item__'; + } + //is this message already displayed ? + if (!messages[_currentMessageGroupId]) { + messages[_currentMessageGroupId] = []; + } + if (_.indexOf(messages[_currentMessageGroupId], message) >= 0) { + return true; //continue + } else { + messages[_currentMessageGroupId].push(message); + } + + //ok, display feedback + renderingQueue.push({ + feedback: feedback, + $container: $container, + order: order + }); + } + }); + + if (renderingQueue.length) { + renderingQueue = _.sortBy(renderingQueue, 'order'); + + //clear previously displayed feedbacks + clearModalFeedbacks($itemContainer); + + //process rendering queue + _.each(renderingQueue, function(renderingToken) { + renderModalFeedback( + renderingToken.feedback, + loader, + renderer, + renderingToken.$container, + $itemContainer, + function(renderingData) { + // keep the first feedback to force focus on it if needed + if (!firstFeedback) { + firstFeedback = $(renderingData.dom); + } + + $('img', renderingData.dom).on('load', function() { + iframeNotifier.parent('itemcontentchange'); + }); + + //record rendered feedback for later reference + renderedFeebacks.push(renderingData); + if (renderedFeebacks.length === renderingQueue.length) { + //rendering processing queue completed + iframeNotifier.parent('itemcontentchange'); + + // set the focus on the first feedback if needed + // TODO: this is heavily related to the old TestRunner, with the ugly iframes. + // To make this working, a search is made accross parent frames. + // When the InlineFeedbacks will be ported to the new TestRunner, a strong improvement will be needed! + if (firstFeedback) { + autoscroll(firstFeedback); + } + + //if an optional "on show modal" callback has been provided, execute it + if (_.isFunction(onShowCallback)) { + onShowCallback(); + } + } + } + ); + }); + + //if any feedback is displayed, replace the controls by a "ok" button + replaceControl(renderedFeebacks, $itemContainer, onCloseCallback); + } + + return renderingQueue.length; +} + +/** + * Gets the QTI Container element + * @returns {jQuery|null} + */ +function getQtiContainer() { + var me = window; + var $container = null; + var max = 10; + + while (me && me.__knownParent__ && max--) { + me = me.parent; + if (me && me.$) { + $container = me.$('#qti-content'); + if ($container.length) { + return $container; + } + } + } + return null; +} + +/** + * Keeps an element visible inside the QTI container. + * If the element is outside the container viewport, scroll to display it. + * @param {String|jQuery|HTMLElement} element + */ +function autoscroll(element) { + var $element = $(element); + var $container = getQtiContainer(); + var currentScrollTop, minScrollTop, maxScrollTop, scrollTop; + + if ($element.length && $container) { + currentScrollTop = $container.scrollTop(); + maxScrollTop = $element.offset().top; + minScrollTop = maxScrollTop - $container.height() + $element.outerHeight(); + scrollTop = Math.max(Math.min(maxScrollTop, currentScrollTop), minScrollTop); + if (scrollTop !== currentScrollTop) { + $container.animate({ scrollTop: scrollTop }); + } + } +} + +/** + * Extract the display information for an interaction-related feedback + * + * @private + * @param {Object} interaction - a qti interaction object + * @returns {Object} Object containing useful display information + */ +function extractDisplayInfo(interaction) { + var $interactionContainer = interaction.getContainer(); + var responseIdentifier = interaction.attr('responseIdentifier'); + var messageGroupId, $displayContainer; + + if (interaction.is('inlineInteraction')) { + $displayContainer = $interactionContainer.closest('[class*=" col-"], [class^="col-"]'); + messageGroupId = $displayContainer.attr('data-messageGroupId'); + if (!messageGroupId) { + //generate a messageFroupId + messageGroupId = _.uniqueId('inline_message_group_'); + $displayContainer.attr('data-messageGroupId', messageGroupId); + } + } else { + messageGroupId = responseIdentifier; + $displayContainer = $interactionContainer; + } + + return { + responseIdentifier: responseIdentifier, + interactionContainer: $interactionContainer, + displayContainer: $displayContainer, + messageGroupId: messageGroupId, + order: -1 + }; +} + +/** + * Get interaction display information sorted in the order of appearance within the item + * + * @param {Object} item + * @returns {Array} + */ +function getInteractionsDisplayInfo(item) { + var interactionsDisplayInfo = []; + var $itemContainer = item.getContainer(); + var interactionOrder = 0; + + //extract all interction related information needed to display their + _.each(item.getComposingElements(), function(element) { + var responseIdentifier; + if (element.is('interaction')) { + responseIdentifier = element.attr('responseIdentifier'); + interactionsDisplayInfo.push(extractDisplayInfo(element)); + } + }); + + //sort interactionsDisplayInfo on the item level + $itemContainer.find('.qti-interaction').each(function() { + var interactionContainer = this; + _.each(interactionsDisplayInfo, function(_interactionInfo) { + if (_interactionInfo.interactionContainer[0] === interactionContainer) { + _interactionInfo.order = interactionOrder; + return false; + } + }); + interactionOrder++; + }); + interactionsDisplayInfo = _.sortBy(interactionsDisplayInfo, 'order'); + + return interactionsDisplayInfo; +} + +/** + * Remove previously displayed feedbacks contained in the given container element + * + * @param {JQuery} $itemContainer + */ +function clearModalFeedbacks($itemContainer) { + $itemContainer.find('.qti-modalFeedback').remove(); +} + +/** + * Render a modal feedback into a given container, scoped within an item container + * + * @private + * @param {type} feedback - feedback object + * @param {type} loader - loader instance + * @param {type} renderer - renderer instance + * @param {JQuery} $container - the targeted feedback container + * @param {JQuery} $itemContainer - the item container + * @param {type} renderedCallback - callback when the feedback has been rendered + * @returns {undefined} + */ +function renderModalFeedback(feedback, loader, renderer, $container, $itemContainer, renderedCallback) { + //load (potential) new qti classes used in the modal feedback (e.g. math, img) + renderer.load(function() { + //render the modal feedback + var $modalFeedback = $( + feedback.render({ + inline: true + }) + ); + var done = function done() { + renderedCallback({ + identifier: feedback.id(), + serial: feedback.getSerial(), + dom: $modalFeedback + }); + }; + $container.append($modalFeedback); + + // Race between postRendering and timeout + // postRendering waits for everything to be resolved or one reject + Promise.race([ + Promise.all( + _.map(feedback.getComposingElements(), function(elt) { + //render also internal elements, such as math or img + return elt.postRender({}, '', renderer).pop(); + }) + ), + new Promise(function(resolve, reject) { + _.delay(reject, timeout, new Error('Post rendering ran out of time.')); + }) + ]) + .then(done) + .catch(function(err) { + //in case of postRendering issue, we are also done + done(); + throw new Error('Error in post rendering : ' + err); + }); + }, loader.getLoadedClasses()); +} + +/** + * Replace the controls in the running environment with an "OK" button to trigger the end of the feedback state + * + * @private + * @todo FIX ME ! replace the hack to preview and delivery toolbar with a proper plugin in the new test runner is ready + * @param {Array} renderedFeebacks + * @param {JQuery} $itemContainer + * @param {Function} callback + */ +function replaceControl(renderedFeebacks, $itemContainer, callback) { + var $scope, $controls, $toggleContainer; + if (window.parent && window.parent.parent && window.parent.parent.$) { + if ($itemContainer.parents('.tao-preview-scope').length) { + //preview mode + $scope = window.parent.parent.$('#preview-console'); + $controls = $scope.find('.preview-console-header .action-bar li:visible'); + $toggleContainer = $scope.find('.console-button-action-bar'); + initControlToggle(renderedFeebacks, $itemContainer, $controls, $toggleContainer, previewOkBtn, callback); + } else { + //delivery delivery + $scope = window.parent.parent.$('body.qti-test-scope .bottom-action-bar'); + $controls = $scope.find('li:visible'); + $toggleContainer = $scope.find('.navi-box-list'); + initControlToggle(renderedFeebacks, $itemContainer, $controls, $toggleContainer, deliveryOkBtn, callback); + } + } else { + //not in an iframe, add to item body for now + $scope = $itemContainer.find('#modalFeedbacks'); + initControlToggle(renderedFeebacks, $itemContainer, $(), $scope, previewOkBtn, callback); + } +} + +/** + * Initialize the "OK" button to trigger the end of the feedback mode + * + * @private + * @param {Array} renderedFeebacks + * @param {JQuery} $itemContainer + * @param {JQuery} $controls + * @param {JQuery} $toggleContainer + * @param {Function} toggleButtonTemplate + * @param {Function} callback + */ +function initControlToggle( + renderedFeebacks, + $itemContainer, + $controls, + $toggleContainer, + toggleButtonTemplate, + callback +) { + var $ok = $(toggleButtonTemplate()).click(function() { + //end feedback mode, hide feedbacks + _.each(renderedFeebacks, function(fb) { + fb.dom.hide(); + }); + + //restore controls + uncover([$itemContainer]); + $ok.remove(); + $controls.show(); + + //exec callback + callback(); + }); + + $controls.hide(); + $toggleContainer.append($ok); + cover([$itemContainer]); +} + +/** + * Cover the given interaction containers with a transparent layer to prevent user interacting with the item + * @private + * @param {Array} interactionContainers + */ +function cover(interactionContainers) { + var $cover = $('