diff --git a/config.js b/config.js deleted file mode 100644 index a9dea7d08..000000000 --- a/config.js +++ /dev/null @@ -1,27 +0,0 @@ -require.config({ - paths: { - jquery: 'empty:', - underscore: 'empty:', - backbone: 'empty:', - modernizr: 'empty:', - handlebars: 'empty:', - velocity: 'empty:', - imageReady: 'empty:', - inview: 'empty:', - a11y: 'empty:', - scrollTo: 'empty:', - libraries: 'empty:', - bowser: 'empty:', - 'core/js/libraries/bowser': 'empty:', - 'coreJS/libraries/bowser': 'empty:' - }, - map: { - '*': { - coreJS: 'core/js', - coreViews: 'core/js/views', - coreModels: 'core/js/models', - coreCollections: 'core/js/collections', - coreHelpers: 'core/js/helpers' - } - } -}); diff --git a/grunt/config/babel.js b/grunt/config/babel.js deleted file mode 100644 index 1d208cc98..000000000 --- a/grunt/config/babel.js +++ /dev/null @@ -1,61 +0,0 @@ -module.exports = { - compile: { - options: { - inputSourceMap: false, - sourceType: 'script', - minified: true, - comments: false, - presets: [ - [ - '@babel/preset-env', - { - targets: { - ie: '11' - }, - spec: true - } - ] - ] - }, - files: [{ - expand: true, - cwd: '<%= tempdir %>', - src: [ - 'adapt.min.js' - ], - dest: '<%= outputdir %>adapt/js/', - ext: '.min.js' - }] - }, - dev: { - options: { - sourceMap: true, - inputSourceMap: true, - sourceType: 'script', - retainLines: true, - minified: false, - compact: false, - comments: true, - presets: [ - [ - '@babel/preset-env', - { - targets: { - ie: '11' - }, - spec: true - } - ] - ] - }, - files: [{ - expand: true, - cwd: '<%= tempdir %>', - src: [ - 'adapt.min.js' - ], - dest: '<%= outputdir %>adapt/js/', - ext: '.min.js' - }] - } -}; diff --git a/grunt/config/clean.js b/grunt/config/clean.js index ad0bcf755..7c7099d04 100644 --- a/grunt/config/clean.js +++ b/grunt/config/clean.js @@ -13,7 +13,8 @@ module.exports = { }, output: { src: [ - '<%= outputdir %>' + '<%= outputdir %>/*', + '!<%= outputdir %>.cache' ] }, temp: { diff --git a/grunt/config/javascript.js b/grunt/config/javascript.js index d2c17c334..563de51ef 100644 --- a/grunt/config/javascript.js +++ b/grunt/config/javascript.js @@ -4,8 +4,8 @@ module.exports = function(grunt, options) { options: { name: 'core/js/app', baseUrl: '<%= sourcedir %>', - mainConfigFile: './config.js', - out: '<%= tempdir %>adapt.min.js', + out: '<%= outputdir %>adapt/js/adapt.min.js', + cachePath: '<%= outputdir %>.cache', // fetch these bower plugins an add them as dependencies to the app.js plugins: [ '<%= sourcedir %>components/*/bower.json', @@ -18,12 +18,33 @@ module.exports = function(grunt, options) { pluginsFilter: function(filepath) { return grunt.config('helpers').includedFilter(filepath); }, - generateSourceMaps: true, - sourceMaps: { - baseUrl: '../../' + external: { + jquery: 'empty:', + underscore: 'empty:', + backbone: 'empty:', + modernizr: 'empty:', + handlebars: 'empty:', + velocity: 'empty:', + imageReady: 'empty:', + inview: 'empty:', + a11y: 'empty:', + scrollTo: 'empty:', + libraries: 'empty:', + bowser: 'empty:', + 'core/js/libraries/bowser': 'empty:', + 'coreJS/libraries/bowser': 'empty:' }, - preserveLicenseComments: false, - optimize: 'none' + map: { + coreJS: 'core/js', + coreViews: 'core/js/views', + coreModels: 'core/js/models', + coreCollections: 'core/js/collections', + coreHelpers: 'core/js/helpers', + // This library from the media component has a circular reference to core/js/adapt, it should be loaded after Adapt + // It needs to be moved from the libraries folder to the js folder + 'libraries/mediaelement-fullscreen-hook': '../libraries/mediaelement-fullscreen-hook' + }, + generateSourceMaps: true }, // newer configuration files: { @@ -36,8 +57,8 @@ module.exports = function(grunt, options) { options: { name: 'core/js/app', baseUrl: '<%= sourcedir %>', - mainConfigFile: './config.js', - out: '<%= tempdir %>adapt.min.js', + out: '<%= outputdir %>adapt/js/adapt.min.js', + cachePath: '<%= outputdir %>.cache', // fetch these bower plugins an add them as dependencies to the app.js plugins: [ '<%= sourcedir %>components/*/bower.json', @@ -50,8 +71,32 @@ module.exports = function(grunt, options) { pluginsFilter: function(filepath) { return grunt.config('helpers').includedFilter(filepath); }, - preserveLicenseComments: false, - optimize: 'none' + external: { + jquery: 'empty:', + underscore: 'empty:', + backbone: 'empty:', + modernizr: 'empty:', + handlebars: 'empty:', + velocity: 'empty:', + imageReady: 'empty:', + inview: 'empty:', + a11y: 'empty:', + scrollTo: 'empty:', + libraries: 'empty:', + bowser: 'empty:', + 'core/js/libraries/bowser': 'empty:', + 'coreJS/libraries/bowser': 'empty:' + }, + map: { + coreJS: 'core/js', + coreViews: 'core/js/views', + coreModels: 'core/js/models', + coreCollections: 'core/js/collections', + coreHelpers: 'core/js/helpers', + // This library from the media component has a circular reference to core/js/adapt, it should be loaded after Adapt + // It needs to be moved from the libraries folder to the js folder + 'libraries/mediaelement-fullscreen-hook': '../libraries/mediaelement-fullscreen-hook' + } } } }; diff --git a/grunt/config/watch.js b/grunt/config/watch.js index 0a18abce8..db090622e 100644 --- a/grunt/config/watch.js +++ b/grunt/config/watch.js @@ -26,7 +26,10 @@ module.exports = { }, js: { files: ['<%= sourcedir %>**/*.js'], - tasks: ['javascript:dev', 'babel:dev', 'clean:temp'] + options: { + spawn: false + }, + tasks: ['javascript:dev', 'clean:temp'] }, componentsAssets: { files: ['<%= sourcedir %>components/**/assets/**'], diff --git a/grunt/tasks/build.js b/grunt/tasks/build.js index 7c2803138..136c9709f 100644 --- a/grunt/tasks/build.js +++ b/grunt/tasks/build.js @@ -13,7 +13,6 @@ module.exports = function(grunt) { 'handlebars', 'tracking-insert', 'javascript:compile', - 'babel:compile', 'clean:dist', 'less:compile', 'replace', diff --git a/grunt/tasks/dev.js b/grunt/tasks/dev.js index 29c3f8d34..457a59ae3 100644 --- a/grunt/tasks/dev.js +++ b/grunt/tasks/dev.js @@ -12,7 +12,6 @@ module.exports = function(grunt) { 'handlebars', 'tracking-insert', 'javascript:dev', - 'babel:dev', 'less:dev', 'replace', 'scripts:adaptpostbuild', diff --git a/grunt/tasks/diff.js b/grunt/tasks/diff.js index 3939933aa..9eb1e426c 100644 --- a/grunt/tasks/diff.js +++ b/grunt/tasks/diff.js @@ -12,7 +12,6 @@ module.exports = function(grunt) { 'newer:handlebars:compile', 'tracking-insert', 'newer:javascript:dev', - 'babel:dev', 'newer:less:dev', 'replace', 'scripts:adaptpostbuild', diff --git a/grunt/tasks/javascript.js b/grunt/tasks/javascript.js index 144657050..53be1aa98 100644 --- a/grunt/tasks/javascript.js +++ b/grunt/tasks/javascript.js @@ -1,80 +1,350 @@ module.exports = function(grunt) { - var convertSlashes = /\\/g; + const convertSlashes = /\\/g; - grunt.registerMultiTask('javascript', 'Compile JavaScript files', function() { + function escapeRegExp(string) { + return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string + } - var requirejs = require('requirejs'); - var path = require('path'); - var fs = require('fs'); - var done = this.async(); - var options = this.options({}); + const path = require('path'); + const fs = require('fs-extra'); + const rollup = require('rollup'); + const { babel, getBabelOutputPlugin } = require('@rollup/plugin-babel'); + const { deflate, unzip, constants } = require('zlib'); - if (options.plugins) { - var pluginsClientSidePatch = ''; + const cwd = process.cwd().replace(convertSlashes, '/') + '/'; + const isDisableCache = process.argv.includes('--disable-cache'); + let cache; - var doesPluginPathExists = true; - try { - fs.statSync(options.pluginsPath); - } catch (e) { - doesPluginPathExists = false; - } + const extensions = ['.js']; + + const restoreCache = async (cachePath, basePath) => { + if (isDisableCache || cache || !fs.existsSync(cachePath)) return; + await new Promise((resolve, reject) => { + const buffer = fs.readFileSync(cachePath); + unzip(buffer, (err, buffer) => { + if (err) { + console.error('An error occurred restoring rollup cache:', err); + process.exitCode = 1; + reject(); + return; + } + let str = buffer.toString(); + // Restore cache to current basePath + str = str.replace(/%%basePath%%/g, basePath); + cache = JSON.parse(str); + resolve(); + }); + }); + }; - if (!doesPluginPathExists) { - // make endpoint for plugin attachment - // apply client side patch - fs.writeFileSync(options.pluginsPath, pluginsClientSidePatch); + const checkCache = function(invalidate) { + if (!cache) return; + const idHash = {}; + const dependents = {}; + const missing = {}; + cache.modules.forEach(mod => { + const moduleId = mod.id; + const isRollupHelper = (moduleId[0] === "\u0000"); + if (isRollupHelper) { + // Ignore as injected rollup module + return null; + } + mod.dependencies.forEach(depId => { + dependents[depId] = dependents[depId] || []; + dependents[depId].push(moduleId); + }); + if (!fs.existsSync(moduleId)) { + grunt.log.error(`Cache missing file: ${moduleId.replace(cwd, '')}`); + missing[moduleId] = true; + return false; } + if (invalidate && invalidate.includes(moduleId)) { + grunt.log.ok(`Cache skipping file: ${moduleId.replace(cwd, '')}`); + return false; + } + idHash[moduleId] = mod; + return true; + }); + Object.keys(missing).forEach(moduleId => { + if (!dependents[moduleId]) return; + dependents[moduleId].forEach(depId => { + if (!idHash[depId]) return; + grunt.log.ok(`Cache invalidating file: ${depId.replace(cwd, '')}`); + delete idHash[depId]; + }); + }); + cache.modules = Object.values(idHash); + }; - options.shim = options.shim || {}; - options.shim[options.pluginsModule] = { - deps: [] - }; + const saveCache = async (cachePath, basePath, bundleCache) => { + if (!isDisableCache) { + cache = bundleCache; + } + await new Promise((resolve, reject) => { + let str = JSON.stringify(bundleCache); + // Make cache location agnostic by stripping current basePath + str = str.replace(new RegExp(escapeRegExp(basePath), 'g'), '%%basePath%%'); + deflate(str, { level: constants.Z_BEST_COMPRESSION }, (err, buffer) => { + if (err) { + console.error('An error occurred saving rollup cache:', err); + process.exitCode = 1; + reject(); + return; + } + fs.writeFileSync(cachePath, buffer); + resolve(); + }); + }); + }; - for (var i = 0, l = options.plugins.length; i < l; i++) { - var src = options.plugins[i]; - grunt.file.expand({ - filter: options.pluginsFilter - }, src).forEach(function(bowerJSONPath) { - if (bowerJSONPath === undefined) return; - var pluginPath = path.dirname(bowerJSONPath); - var bowerJSON = grunt.file.readJSON(bowerJSONPath); - var requireJSRootPath = pluginPath.substr(options.baseUrl.length); - var requireJSMainPath = path.join(requireJSRootPath, bowerJSON.main); - var ext = path.extname(requireJSMainPath); - var requireJSMainPathNoExt = requireJSMainPath.slice(0, -ext.length).replace(convertSlashes, '/'); - options.shim[options.pluginsModule].deps.push(requireJSMainPathNoExt); - }); + const logPrettyError = (err, cachePath, basePath) => { + let hasOutput = false; + if (err.loc) { + // Code error + switch (err.plugin) { + case 'babel': + err.frame = err.message.substr(err.message.indexOf('\n')+1); + err.message = err.message.substr(0, err.message.indexOf('\n')).slice(2).replace(/^([^:]*): /, ''); + break; + default: + hasOutput = true; + console.log('error', err); } + if (!hasOutput) { + grunt.log.error(err.message); + grunt.log.error(`Line: ${err.loc.line}, Col: ${err.loc.column}, File: ${err.id.replace(cwd, '')}`); + console.log(err.frame); + hasOutput = true; + } + } + if (!hasOutput) { + cache = null; + saveCache(cachePath, basePath, cache); + console.log(err); } + }; - var mapPath = options.out + '.map'; + grunt.registerMultiTask('javascript', 'Compile JavaScript files', async function() { + grunt.log.ok(`Cache disabled (--disable-cache): ${isDisableCache}`); + const done = this.async(); + const options = this.options({}); + const isSourceMapped = Boolean(options.generateSourceMaps); + const basePath = path.resolve(cwd + '/' + options.baseUrl).replace(convertSlashes, '/') + '/'; + await restoreCache(options.cachePath, basePath); + const pluginsPath = path.resolve(cwd, options.pluginsPath).replace(convertSlashes, '/'); - requirejs.optimize(options, function() { - if (!options.generateSourceMaps) return done(); - fixSourceMapBaseUrl(); - }, function(error) { - grunt.fail.fatal(error); - }); + // Make src/plugins.js to attach the plugins dynamically + if (!fs.existsSync(pluginsPath)) { + fs.writeFileSync(pluginsPath, ''); + } - function fixSourceMapBaseUrl() { - if (!fs.existsSync(mapPath)) return done(); - if (!options.sourceMaps || !options.sourceMaps.baseUrl) return done(); - fs.readFile(mapPath, 'utf8', readSourceMap); + // Collect all plugin entry points for injection + const pluginPaths = []; + for (let i = 0, l = options.plugins.length; i < l; i++) { + const src = options.plugins[i]; + grunt.file.expand({ + filter: options.pluginsFilter + }, src).forEach(function(bowerJSONPath) { + if (bowerJSONPath === undefined) return; + const pluginPath = path.dirname(bowerJSONPath); + const bowerJSON = grunt.file.readJSON(bowerJSONPath); + const requireJSRootPath = pluginPath.substr(options.baseUrl.length); + const requireJSMainPath = path.join(requireJSRootPath, bowerJSON.main); + const ext = path.extname(requireJSMainPath); + const requireJSMainPathNoExt = requireJSMainPath.slice(0, -ext.length).replace(convertSlashes, '/'); + pluginPaths.push(requireJSMainPathNoExt); + }); } - function readSourceMap(error, data) { - if (error) { - grunt.fail.fatal(error); - return done(); + // Process remapping and external model configurations + const mapParts = Object.keys(options.map); + const externalParts = Object.keys(options.external); + + const findFile = function(filename) { + filename = filename.replace(convertSlashes, '/'); + const hasValidExtension = extensions.includes(path.parse(filename).ext); + if (!hasValidExtension) { + const ext = extensions.find(ext => fs.existsSync(filename + ext)) || ''; + filename += ext; } - var sourcemap = JSON.parse(data); - var baseUrl = options.sourceMaps.baseUrl; - sourcemap.sources = sourcemap.sources.map(function(path) { - return baseUrl + path; - }); - fs.writeFile(mapPath, JSON.stringify(sourcemap), done); + return filename; }; + // Rework modules names and inject plugins + const adaptLoader = function() { + return { + + name: 'adaptLoader', + + resolveId(moduleId, parentId) { + const isRollupHelper = (moduleId[0] === "\u0000"); + if (isRollupHelper) { + // Ignore as injected rollup module + return null; + } + const mapPart = mapParts.find(part => moduleId.startsWith(part)); + if (mapPart) { + // Remap module, usually coreJS/adapt to core/js/adapt etc + moduleId = moduleId.replace(mapPart, options.map[mapPart]); + } + const isRelative = (moduleId[0] === '.'); + if (isRelative) { + if (!parentId) { + // Rework app.js path so that it can be made basePath agnostic in the cache + const filename = findFile(path.resolve(moduleId)); + return { + id: filename, + external: false + }; + } + // Rework relative paths into absolute ones + const filename = findFile(path.resolve(parentId + '/../' + moduleId)); + return { + id: filename, + external: false + }; + } + const externalPart = externalParts.find(part => moduleId.startsWith(part)); + const isEmpty = (options.external[externalPart] === 'empty:'); + if (isEmpty) { + // External module as is defined as 'empty:', libraries/ bower handlebars etc + return { + id: moduleId, + external: true + }; + } + const isES6Import = !fs.existsSync(moduleId); + if (isES6Import) { + // ES6 imports start inside ./src so need correcting + const filename = findFile(path.resolve(cwd, options.baseUrl, moduleId)); + return { + id: filename, + external: false + }; + } + // Normalize all other absolute paths as conflicting slashes will load twice + const filename = findFile(path.resolve(cwd, moduleId)); + return { + id: filename, + external: false + }; + } + + }; + }; + + const adaptInjectPlugins = function() { + return { + + name: 'adaptInjectPlugins', + + transform(code, moduleId) { + const isRollupHelper = (moduleId[0] === "\u0000"); + if (isRollupHelper) { + return null; + } + const isPlugins = (moduleId.includes('/'+options.pluginsModule+'.js')); + if (!isPlugins) { + return null; + } + // Dynamically construct plugins.js with plugin dependencies + code = `define([${pluginPaths.map(filename => { + return `"${filename}"`; + }).join(',')}], function() {});`; + return code; + } + + }; + }; + + const inputOptions = { + input: './' + options.baseUrl + options.name, + shimMissingExports: true, + plugins: [ + adaptLoader({}), + adaptInjectPlugins({}), + babel({ + babelHelpers: 'bundled', + extensions, + minified: false, + compact: false, + comments: false, + exclude: [ + "**/node_modules/**" + ], + presets: [ + [ + '@babel/preset-env', + { + targets: { + ie: '11' + }, + exclude: [ + // Breaks lockingModel.js, set function vs set variable + "transform-function-name" + ], + } + ] + ], + plugins: [ + [ + 'transform-amd-to-es6', + { + amdToES6Modules: true, + amdDefineES6Modules: true, + defineFunctionName: '__AMD', + defineModuleId: (moduleId) => moduleId.replace(convertSlashes,'/').replace(basePath, '').replace('\.js', '') + } + ] + ] + }) + ], + cache + }; + + const outputOptions = { + file: options.out, + format: 'amd', + plugins: [ + !isSourceMapped && getBabelOutputPlugin({ + minified: true, + compact: true, + comments: false, + allowAllFormats: true + }) + ].filter(Boolean), + footer: `// Allow ES export default to be exported as amd modules +window.__AMD = function(id, value) { + window.define(id, function() { return value; }); // define for external use + window.require([id]); // force module to load + return value; // return for export +};`, + sourcemap: isSourceMapped, + sourcemapPathTransform: (relativeSourcePath) => { + // Rework sourcemap paths to overlay at the appropriate root + return relativeSourcePath.replace(convertSlashes, '/').replace('../' + options.baseUrl, ''); + }, + amd: { + define: 'require' + } + }; + + try { + checkCache([pluginsPath]); + const bundle = await rollup.rollup(inputOptions); + await saveCache(options.cachePath, basePath, bundle.cache); + await bundle.write(outputOptions); + } catch (err) { + logPrettyError(err, options.cachePath, basePath); + } + + // Remove old sourcemap if no longer required + if (!isSourceMapped && fs.existsSync(options.out + ".map")) { + fs.unlinkSync(options.out + ".map"); + } + + done(); + }); }; diff --git a/grunt/tasks/server-build.js b/grunt/tasks/server-build.js index 5397490aa..33cebe188 100644 --- a/grunt/tasks/server-build.js +++ b/grunt/tasks/server-build.js @@ -13,7 +13,6 @@ module.exports = function(grunt) { 'less:' + requireMode, 'handlebars', 'javascript:' + requireMode, - 'babel:' + requireMode, 'replace', 'scripts:adaptpostbuild', 'clean:temp' diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 000000000..0ccb50e26 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,15 @@ +{ + "include": [ + "src/core/js/**/*.js", + "src/*/*/js/**/*.js" + ], + "compilerOptions": { + "module": "amd", + "target": "es2015", + "baseUrl": "src/" + }, + "exclude": [ + "node_modules", + "**/node_modules/*" + ] +} diff --git a/package.json b/package.json index 1f9e98c1d..9c282a6d4 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,10 @@ "dependencies": { "@babel/core": "^7.8.4", "@babel/preset-env": "^7.8.4", + "@rollup/plugin-babel": "^5.0.4", "@types/backbone": "^1.4.1", "@types/jquery": "^3.3.31", + "babel-plugin-transform-amd-to-es6": "^0.3.0", "async": "^3.1.1", "chalk": "^2.4.1", "columnify": "^1.5.4", @@ -26,7 +28,6 @@ "fs-extra": "^8.1.0", "globs": "^0.1.4", "grunt": "^1.0.3", - "grunt-babel": "^8.0.0", "grunt-concurrent": "^2.3.1", "grunt-contrib-clean": "^2.0.0", "grunt-contrib-connect": "^2.0.0", @@ -47,7 +48,7 @@ "load-grunt-config": "^1.0.1", "lodash": "^4.17.15", "nsdeclare": "^0.1.0", - "requirejs": "^2.3.6", + "rollup": "^2.18.1", "time-grunt": "^2.0.0", "underscore": "^1.9.1", "underscore-deep-extend": "^1.1.5" diff --git a/src/core/js/a11y/popup.js b/src/core/js/a11y/popup.js index 6eca414cd..afa70b7c3 100644 --- a/src/core/js/a11y/popup.js +++ b/src/core/js/a11y/popup.js @@ -202,7 +202,7 @@ define([ } } }.bind(this)); - return (document.activeElement = this._focusStack.pop()); + return this._focusStack.pop(); }, /** diff --git a/src/core/js/adapt.js b/src/core/js/adapt.js index 614c3c756..4b5f90f91 100644 --- a/src/core/js/adapt.js +++ b/src/core/js/adapt.js @@ -1,417 +1,414 @@ -define([ - 'core/js/wait', - 'core/js/models/lockingModel' -], function(Wait) { - - class Adapt extends Backbone.Model { - - initialize() { - this.loadScript = window.__loadScript; - this.location = {}; - this.store = {}; - this.setupWait(); - } +import Wait from 'core/js/wait'; +import 'core/js/models/lockingModel'; - defaults() { - return { - _canScroll: true, // to stop scrollTo behaviour, - _outstandingCompletionChecks: 0, - _pluginWaitCount: 0, - _isStarted: false, - _shouldDestroyContentObjects: true - }; - } +class AdaptSingleton extends Backbone.Model { - lockedAttributes() { - return { - _canScroll: false - }; - } + initialize() { + this.loadScript = window.__loadScript; + this.location = {}; + this.store = {}; + this.setupWait(); + } - /** - * @deprecated since v6.0.0 - please use `Adapt.store` instead - */ - get componentStore() { - this.log && this.log.deprecated('Adapt.componentStore, please use Adapt.store instead'); - return this.store; - } + defaults() { + return { + _canScroll: true, // to stop scrollTo behaviour, + _outstandingCompletionChecks: 0, + _pluginWaitCount: 0, + _isStarted: false, + _shouldDestroyContentObjects: true + }; + } - init() { - this.addDirection(); - this.disableAnimation(); - this.trigger('adapt:preInitialize'); + lockedAttributes() { + return { + _canScroll: false + }; + } - // wait until no more completion checking - this.deferUntilCompletionChecked(() => { + /** + * @deprecated since v6.0.0 - please use `Adapt.store` instead + */ + get componentStore() { + this.log && this.log.deprecated('Adapt.componentStore, please use Adapt.store instead'); + return this.store; + } - // start adapt in a full restored state - this.trigger('adapt:start'); + init() { + this.addDirection(); + this.disableAnimation(); + this.trigger('adapt:preInitialize'); - if (!Backbone.History.started) { - Backbone.history.start(); - } + // wait until no more completion checking + this.deferUntilCompletionChecked(() => { - this.set('_isStarted', true); + // start adapt in a full restored state + this.trigger('adapt:start'); - this.trigger('adapt:initialize'); + if (!Backbone.History.started) { + Backbone.history.start(); + } - }); - } + this.set('_isStarted', true); - /** - * call when entering an asynchronous completion check - */ - checkingCompletion() { - const outstandingChecks = this.get('_outstandingCompletionChecks'); - this.set('_outstandingCompletionChecks', outstandingChecks + 1); - } + this.trigger('adapt:initialize'); - /** - * call when exiting an asynchronous completion check - */ - checkedCompletion() { - const outstandingChecks = this.get('_outstandingCompletionChecks'); - this.set('_outstandingCompletionChecks', outstandingChecks - 1); - } + }); + } - /** - * wait until there are no outstanding completion checks - * @param {Function} callback Function to be called after all completion checks have been completed - */ - deferUntilCompletionChecked(callback) { - if (this.get('_outstandingCompletionChecks') === 0) return callback(); + /** + * call when entering an asynchronous completion check + */ + checkingCompletion() { + const outstandingChecks = this.get('_outstandingCompletionChecks'); + this.set('_outstandingCompletionChecks', outstandingChecks + 1); + } - const checkIfAnyChecksOutstanding = (model, outstandingChecks) => { - if (outstandingChecks !== 0) return; - this.off('change:_outstandingCompletionChecks', checkIfAnyChecksOutstanding); - callback(); - }; + /** + * call when exiting an asynchronous completion check + */ + checkedCompletion() { + const outstandingChecks = this.get('_outstandingCompletionChecks'); + this.set('_outstandingCompletionChecks', outstandingChecks - 1); + } - this.on('change:_outstandingCompletionChecks', checkIfAnyChecksOutstanding); + /** + * wait until there are no outstanding completion checks + * @param {Function} callback Function to be called after all completion checks have been completed + */ + deferUntilCompletionChecked(callback) { + if (this.get('_outstandingCompletionChecks') === 0) return callback(); - } + const checkIfAnyChecksOutstanding = (model, outstandingChecks) => { + if (outstandingChecks !== 0) return; + this.off('change:_outstandingCompletionChecks', checkIfAnyChecksOutstanding); + callback(); + }; - setupWait() { - - this.wait = new Wait(); - - // Setup legacy events and handlers - const beginWait = () => { - this.log.deprecated(`Use Adapt.wait.begin() as Adapt.trigger('plugin:beginWait') may be removed in the future`); - this.wait.begin(); - }; - - const endWait = () => { - this.log.deprecated(`Use Adapt.wait.end() as Adapt.trigger('plugin:endWait') may be removed in the future`); - this.wait.end(); - }; - - const ready = () => { - if (this.wait.isWaiting()) { - return; - } - const isEventListening = (this._events['plugins:ready']); - if (!isEventListening) { - return; - } - this.log.deprecated("Use Adapt.wait.queue(callback) as Adapt.on('plugins:ready', callback) may be removed in the future"); - this.trigger('plugins:ready'); - }; - - this.listenTo(this.wait, 'ready', ready); - this.listenTo(this, { - 'plugin:beginWait': beginWait, - 'plugin:endWait': endWait - }); + this.on('change:_outstandingCompletionChecks', checkIfAnyChecksOutstanding); - } + } - isWaitingForPlugins() { - this.log.deprecated('Use Adapt.wait.isWaiting() as Adapt.isWaitingForPlugins() may be removed in the future'); - return this.wait.isWaiting(); - } + setupWait() { - checkPluginsReady() { - this.log.deprecated('Use Adapt.wait.isWaiting() as Adapt.checkPluginsReady() may be removed in the future'); - if (this.isWaitingForPlugins()) { - return; - } - this.trigger('plugins:ready'); - } + this.wait = new Wait(); - /** - * Allows a selector to be passed in and Adapt will navigate to this element - * @param {string} selector CSS selector of the Adapt element you want to navigate to e.g. `".co-05"` - * @param {object} [settings={}] The settings for the `$.scrollTo` function (See https://github.com/flesler/jquery.scrollTo#settings). - * You may also include a `replace` property that you can set to `true` if you want to update the URL without creating an entry in the browser's history. - */ - navigateToElement() {} - - /** - * Allows a selector to be passed in and Adapt will scroll to this element - * @param {string} selector CSS selector of the Adapt element you want to navigate to e.g. `".co-05"` - * @param {object} [settings={}] The settings for the `$.scrollTo` function (See https://github.com/flesler/jquery.scrollTo#settings). - * You may also include a `replace` property that you can set to `true` if you want to update the URL without creating an entry in the browser's history. - */ - scrollTo() {} - - /** - * Used to register models and views with `Adapt.store` - * @param {string|Array} name The name(s) of the model/view to be registered - * @param {object} object Object containing properties `model` and `view` or (legacy) an object representing the view - */ - register(name, object) { - if (Array.isArray(name)) { - // if an array is passed, iterate by recursive call - name.forEach(name => this.register(name, object)); - return object; - } + // Setup legacy events and handlers + const beginWait = () => { + this.log.deprecated(`Use Adapt.wait.begin() as Adapt.trigger('plugin:beginWait') may be removed in the future`); + this.wait.begin(); + }; - if (name.split(' ').length > 1) { - // if name with spaces is passed, split and pass as array - this.register(name.split(' '), object); - return object; - } + const endWait = () => { + this.log.deprecated(`Use Adapt.wait.end() as Adapt.trigger('plugin:endWait') may be removed in the future`); + this.wait.end(); + }; - if ((!object.view && !object.model) || object instanceof Backbone.View) { - this.log && this.log.deprecated('View-only registrations are no longer supported'); - object = { view: object }; + const ready = () => { + if (this.wait.isWaiting()) { + return; } - - if (object.view && !object.view.template) { - object.view.template = name; + const isEventListening = (this._events['plugins:ready']); + if (!isEventListening) { + return; } + this.log.deprecated("Use Adapt.wait.queue(callback) as Adapt.on('plugins:ready', callback) may be removed in the future"); + this.trigger('plugins:ready'); + }; - const isModelSetAndInvalid = (object.model && - !(object.model.prototype instanceof Backbone.Model) && - !(object.model instanceof Function)); - if (isModelSetAndInvalid) { - throw new Error('The registered model is not a Backbone.Model or Function'); - } + this.listenTo(this.wait, 'ready', ready); + this.listenTo(this, { + 'plugin:beginWait': beginWait, + 'plugin:endWait': endWait + }); - const isViewSetAndInvalid = (object.view && - !(object.view.prototype instanceof Backbone.View) && - !(object.view instanceof Function)); - if (isViewSetAndInvalid) { - throw new Error('The registered view is not a Backbone.View or Function'); - } + } - this.store[name] = Object.assign({}, this.store[name], object); + isWaitingForPlugins() { + this.log.deprecated('Use Adapt.wait.isWaiting() as Adapt.isWaitingForPlugins() may be removed in the future'); + return this.wait.isWaiting(); + } - return object; + checkPluginsReady() { + this.log.deprecated('Use Adapt.wait.isWaiting() as Adapt.checkPluginsReady() may be removed in the future'); + if (this.isWaitingForPlugins()) { + return; } + this.trigger('plugins:ready'); + } - /** - * Parses a view class name. - * @param {string|Backbone.Model|Backbone.View|object} nameModelViewOrData The name of the view class you want to fetch e.g. `"hotgraphic"` or its model or its json data - */ - getViewName(nameModelViewOrData) { - if (typeof nameModelViewOrData === 'string') { - return nameModelViewOrData; - } - if (nameModelViewOrData instanceof Backbone.Model) { - nameModelViewOrData = nameModelViewOrData.toJSON(); - } - if (nameModelViewOrData instanceof Backbone.View) { - let foundName; - _.find(this.store, (entry, name) => { - if (!entry || !entry.view) return; - if (!(nameModelViewOrData instanceof entry.view)) return; - foundName = name; - return true; - }); - return foundName; - } - if (nameModelViewOrData instanceof Object) { - const names = [ - typeof nameModelViewOrData._view === 'string' && nameModelViewOrData._view, - typeof nameModelViewOrData._component === 'string' && nameModelViewOrData._component, - typeof nameModelViewOrData._type === 'string' && nameModelViewOrData._type - ].filter(Boolean); - if (names.length) { - // find first fitting view name - const name = names.find(name => this.store[name] && this.store[name].view); - return name || names.pop(); // return last available if none found - } - } - throw new Error('Cannot derive view class name from input'); + /** + * Allows a selector to be passed in and Adapt will navigate to this element + * @param {string} selector CSS selector of the Adapt element you want to navigate to e.g. `".co-05"` + * @param {object} [settings={}] The settings for the `$.scrollTo` function (See https://github.com/flesler/jquery.scrollTo#settings). + * You may also include a `replace` property that you can set to `true` if you want to update the URL without creating an entry in the browser's history. + */ + navigateToElement() {} + + /** + * Allows a selector to be passed in and Adapt will scroll to this element + * @param {string} selector CSS selector of the Adapt element you want to navigate to e.g. `".co-05"` + * @param {object} [settings={}] The settings for the `$.scrollTo` function (See https://github.com/flesler/jquery.scrollTo#settings). + * You may also include a `replace` property that you can set to `true` if you want to update the URL without creating an entry in the browser's history. + */ + scrollTo() {} + + /** + * Used to register models and views with `Adapt.store` + * @param {string|Array} name The name(s) of the model/view to be registered + * @param {object} object Object containing properties `model` and `view` or (legacy) an object representing the view + */ + register(name, object) { + if (Array.isArray(name)) { + // if an array is passed, iterate by recursive call + name.forEach(name => this.register(name, object)); + return object; } - /** - * Fetches a view class from the store. For a usage example, see either HotGraphic or Narrative - * @param {string|Backbone.Model|Backbone.View|object} nameModelViewOrData The name of the view class you want to fetch e.g. `"hotgraphic"` or its model or its json data - * @returns {Backbone.View} Reference to the view class - */ - getViewClass(nameModelViewOrData) { - const name = this.getViewName(nameModelViewOrData); - const object = this.store[name]; - if (!object) { - this.log.warnOnce(`A view for '${name}' isn't registered in your project`); - return; - } - const isBackboneView = (object.view && object.view.prototype instanceof Backbone.View); - if (!isBackboneView && object.view instanceof Function) { - return object.view(); - } - return object.view; + if (name.split(' ').length > 1) { + // if name with spaces is passed, split and pass as array + this.register(name.split(' '), object); + return object; } - /** - * Parses a model class name. - * @param {string|Backbone.Model|object} name The name of the model you want to fetch e.g. `"hotgraphic"`, the model to process or its json data - */ - getModelName(nameModelOrData) { - if (typeof nameModelOrData === 'string') { - return nameModelOrData; - } - if (nameModelOrData instanceof Backbone.Model) { - nameModelOrData = nameModelOrData.toJSON(); - } - if (nameModelOrData instanceof Object) { - const names = [ - typeof nameModelOrData._model === 'string' && nameModelOrData._model, - typeof nameModelOrData._component === 'string' && nameModelOrData._component, - typeof nameModelOrData._type === 'string' && nameModelOrData._type - ].filter(Boolean); - if (names.length) { - // find first fitting model name - const name = names.find(name => this.store[name] && this.store[name].model); - return name || names.pop(); // return last available if none found - } - } - throw new Error('Cannot derive model class name from input'); + if ((!object.view && !object.model) || object instanceof Backbone.View) { + this.log && this.log.deprecated('View-only registrations are no longer supported'); + object = { view: object }; } - /** - * Fetches a model class from the store. For a usage example, see either HotGraphic or Narrative - * @param {string|Backbone.Model|object} name The name of the model you want to fetch e.g. `"hotgraphic"` or its json data - * @returns {Backbone.Model} Reference to the view class - */ - getModelClass(nameModelOrData) { - const name = this.getModelName(nameModelOrData); - const object = this.store[name]; - if (!object) { - this.log.warnOnce(`A model for '${name}' isn't registered in your project`); - return; - } - const isBackboneModel = (object.model && object.model.prototype instanceof Backbone.Model); - if (!isBackboneModel && object.model instanceof Function) { - return object.model(); - } - return object.model; + if (object.view && !object.view.template) { + object.view.template = name; } - /** - * Looks up which collection a model belongs to - * @param {string} id The id of the item you want to look up e.g. `"co-05"` - * @return {string} One of the following (or `undefined` if not found): - * - "course" - * - "contentObjects" - * - "blocks" - * - "articles" - * - "components" - */ - mapById(id) { - return this.data.mapById(id); + const isModelSetAndInvalid = (object.model && + !(object.model.prototype instanceof Backbone.Model) && + !(object.model instanceof Function)); + if (isModelSetAndInvalid) { + throw new Error('The registered model is not a Backbone.Model or Function'); } - /** - * Looks up a model by its `_id` property - * @param {string} id The id of the item e.g. "co-05" - * @return {Backbone.Model} - */ - findById(id) { - return this.data.findById(id); + const isViewSetAndInvalid = (object.view && + !(object.view.prototype instanceof Backbone.View) && + !(object.view instanceof Function)); + if (isViewSetAndInvalid) { + throw new Error('The registered view is not a Backbone.View or Function'); } - findViewByModelId(id) { - const model = this.data.findById(id); - if (!model) return; + this.store[name] = Object.assign({}, this.store[name], object); - if (model === this.parentView.model) return this.parentView; + return object; + } - const idPathToView = [id]; - const currentLocationId = this.location._currentId; - const currentLocationModel = model.getAncestorModels().find(model => { - const modelId = model.get('_id'); - if (modelId === currentLocationId) return true; - idPathToView.unshift(modelId); + /** + * Parses a view class name. + * @param {string|Backbone.Model|Backbone.View|object} nameModelViewOrData The name of the view class you want to fetch e.g. `"hotgraphic"` or its model or its json data + */ + getViewName(nameModelViewOrData) { + if (typeof nameModelViewOrData === 'string') { + return nameModelViewOrData; + } + if (nameModelViewOrData instanceof Backbone.Model) { + nameModelViewOrData = nameModelViewOrData.toJSON(); + } + if (nameModelViewOrData instanceof Backbone.View) { + let foundName; + _.find(this.store, (entry, name) => { + if (!entry || !entry.view) return; + if (!(nameModelViewOrData instanceof entry.view)) return; + foundName = name; + return true; }); - - if (!currentLocationModel) { - return console.warn(`Adapt.findViewByModelId() unable to find view for model id: ${id}`); + return foundName; + } + if (nameModelViewOrData instanceof Object) { + const names = [ + typeof nameModelViewOrData._view === 'string' && nameModelViewOrData._view, + typeof nameModelViewOrData._component === 'string' && nameModelViewOrData._component, + typeof nameModelViewOrData._type === 'string' && nameModelViewOrData._type + ].filter(Boolean); + if (names.length) { + // find first fitting view name + const name = names.find(name => this.store[name] && this.store[name].view); + return name || names.pop(); // return last available if none found } + } + throw new Error('Cannot derive view class name from input'); + } - const foundView = idPathToView.reduce((view, currentId) => { - return view && view.childViews && view.childViews[currentId]; - }, this.parentView); + /** + * Fetches a view class from the store. For a usage example, see either HotGraphic or Narrative + * @param {string|Backbone.Model|Backbone.View|object} nameModelViewOrData The name of the view class you want to fetch e.g. `"hotgraphic"` or its model or its json data + * @returns {Backbone.View} Reference to the view class + */ + getViewClass(nameModelViewOrData) { + const name = this.getViewName(nameModelViewOrData); + const object = this.store[name]; + if (!object) { + this.log.warnOnce(`A view for '${name}' isn't registered in your project`); + return; + } + const isBackboneView = (object.view && object.view.prototype instanceof Backbone.View); + if (!isBackboneView && object.view instanceof Function) { + return object.view(); + } + return object.view; + } - return foundView; + /** + * Parses a model class name. + * @param {string|Backbone.Model|object} name The name of the model you want to fetch e.g. `"hotgraphic"`, the model to process or its json data + */ + getModelName(nameModelOrData) { + if (typeof nameModelOrData === 'string') { + return nameModelOrData; } + if (nameModelOrData instanceof Backbone.Model) { + nameModelOrData = nameModelOrData.toJSON(); + } + if (nameModelOrData instanceof Object) { + const names = [ + typeof nameModelOrData._model === 'string' && nameModelOrData._model, + typeof nameModelOrData._component === 'string' && nameModelOrData._component, + typeof nameModelOrData._type === 'string' && nameModelOrData._type + ].filter(Boolean); + if (names.length) { + // find first fitting model name + const name = names.find(name => this.store[name] && this.store[name].model); + return name || names.pop(); // return last available if none found + } + } + throw new Error('Cannot derive model class name from input'); + } - /** - * Relative strings describe the number and type of hops in the model hierarchy - * @param {string} relativeString "@component +1" means to move one component forward from the current model - * This function would return the following: - * { - * type: "component", - * offset: 1 - * } - * Trickle uses this function to determine where it should scrollTo after it unlocks - */ - parseRelativeString(relativeString) { - const splitIndex = relativeString.search(/[ +\-\d]{1}/); - const type = relativeString.slice(0, splitIndex).replace(/^@/, ''); - const offset = parseInt(relativeString.slice(splitIndex).trim() || 0); - return { - type: type, - offset: offset - }; + /** + * Fetches a model class from the store. For a usage example, see either HotGraphic or Narrative + * @param {string|Backbone.Model|object} name The name of the model you want to fetch e.g. `"hotgraphic"` or its json data + * @returns {Backbone.Model} Reference to the view class + */ + getModelClass(nameModelOrData) { + const name = this.getModelName(nameModelOrData); + const object = this.store[name]; + if (!object) { + this.log.warnOnce(`A model for '${name}' isn't registered in your project`); + return; + } + const isBackboneModel = (object.model && object.model.prototype instanceof Backbone.Model); + if (!isBackboneModel && object.model instanceof Function) { + return object.model(); } + return object.model; + } - addDirection() { - const defaultDirection = this.config.get('_defaultDirection'); + /** + * Looks up which collection a model belongs to + * @param {string} id The id of the item you want to look up e.g. `"co-05"` + * @return {string} One of the following (or `undefined` if not found): + * - "course" + * - "contentObjects" + * - "blocks" + * - "articles" + * - "components" + */ + mapById(id) { + return this.data.mapById(id); + } - $('html') - .addClass('dir-' + defaultDirection) - .attr('dir', defaultDirection); - } + /** + * Looks up a model by its `_id` property + * @param {string} id The id of the item e.g. "co-05" + * @return {Backbone.Model} + */ + findById(id) { + return this.data.findById(id); + } - disableAnimation() { - const disableAnimationArray = this.config.get('_disableAnimationFor'); - const disableAnimation = this.config.get('_disableAnimation'); - - // Check if animations should be disabled - if (disableAnimationArray) { - for (let i = 0, l = disableAnimationArray.length; i < l; i++) { - if (!$('html').is(disableAnimationArray[i])) continue; - this.config.set('_disableAnimation', true); - $('html').addClass('disable-animation'); - console.log('Animation disabled.'); - } - return; - } + findViewByModelId(id) { + const model = this.data.findById(id); + if (!model) return; - $('html').toggleClass('disable-animation', (disableAnimation === true)); + if (model === this.parentView.model) return this.parentView; + + const idPathToView = [id]; + const currentLocationId = this.location._currentId; + const currentLocationModel = model.getAncestorModels().find(model => { + const modelId = model.get('_id'); + if (modelId === currentLocationId) return true; + idPathToView.unshift(modelId); + }); + + if (!currentLocationModel) { + return console.warn(`Adapt.findViewByModelId() unable to find view for model id: ${id}`); } - async remove() { - const currentView = this.parentView; - if (currentView) { - currentView.model.setOnChildren('_isReady', false); - currentView.model.set('_isReady', false); - } - this.trigger('preRemove', currentView); - await this.wait.queue(); - // Facilitate contentObject transitions - if (currentView && this.get('_shouldDestroyContentObjects')) { - currentView.destroy(); + const foundView = idPathToView.reduce((view, currentId) => { + return view && view.childViews && view.childViews[currentId]; + }, this.parentView); + + return foundView; + } + + /** + * Relative strings describe the number and type of hops in the model hierarchy + * @param {string} relativeString "@component +1" means to move one component forward from the current model + * This function would return the following: + * { + * type: "component", + * offset: 1 + * } + * Trickle uses this function to determine where it should scrollTo after it unlocks + */ + parseRelativeString(relativeString) { + const splitIndex = relativeString.search(/[ +\-\d]{1}/); + const type = relativeString.slice(0, splitIndex).replace(/^@/, ''); + const offset = parseInt(relativeString.slice(splitIndex).trim() || 0); + return { + type: type, + offset: offset + }; + } + + addDirection() { + const defaultDirection = this.config.get('_defaultDirection'); + + $('html') + .addClass('dir-' + defaultDirection) + .attr('dir', defaultDirection); + } + + disableAnimation() { + const disableAnimationArray = this.config.get('_disableAnimationFor'); + const disableAnimation = this.config.get('_disableAnimation'); + + // Check if animations should be disabled + if (disableAnimationArray) { + for (let i = 0, l = disableAnimationArray.length; i < l; i++) { + if (!$('html').is(disableAnimationArray[i])) continue; + this.config.set('_disableAnimation', true); + $('html').addClass('disable-animation'); + console.log('Animation disabled.'); } - this.trigger('remove', currentView); - _.defer(this.trigger.bind(this), 'postRemove', currentView); + return; } + $('html').toggleClass('disable-animation', (disableAnimation === true)); } - return new Adapt(); -}); + async remove() { + const currentView = this.parentView; + if (currentView) { + currentView.model.setOnChildren('_isReady', false); + currentView.model.set('_isReady', false); + } + this.trigger('preRemove', currentView); + await this.wait.queue(); + // Facilitate contentObject transitions + if (currentView && this.get('_shouldDestroyContentObjects')) { + currentView.destroy(); + } + this.trigger('remove', currentView); + _.defer(this.trigger.bind(this), 'postRemove', currentView); + } + +} + +export default new AdaptSingleton(); diff --git a/src/core/js/app.js b/src/core/js/app.js index 7256ca15e..051f6bb46 100644 --- a/src/core/js/app.js +++ b/src/core/js/app.js @@ -1,33 +1,29 @@ -require([ - 'core/js/adapt', - 'core/js/templates', - 'core/js/fixes', - 'core/js/accessibility', - 'core/js/data', - 'core/js/offlineStorage', - 'core/js/logging', - 'core/js/tracking', - 'core/js/device', - 'core/js/drawer', - 'core/js/notify', - 'core/js/router', - 'core/js/models/lockingModel', - 'core/js/mpabc', - 'core/js/helpers', - 'core/js/scrolling', - 'core/js/headings', - 'core/js/navigation', - 'plugins' -], function (Adapt) { +import Adapt from 'core/js/adapt'; +import 'core/js/templates'; +import 'core/js/fixes'; +import 'core/js/accessibility'; +import 'core/js/data'; +import 'core/js/offlineStorage'; +import 'core/js/logging'; +import 'core/js/tracking'; +import 'core/js/device'; +import 'core/js/drawer'; +import 'core/js/notify'; +import 'core/js/router'; +import 'core/js/models/lockingModel'; +import 'core/js/mpabc'; +import 'core/js/helpers'; +import 'core/js/scrolling'; +import 'core/js/headings'; +import 'core/js/navigation'; +import 'plugins'; - $('body').append(Handlebars.templates.loading()); +$('body').append(Handlebars.templates.loading()); - Adapt.data.on('ready', function triggerInit() { - Adapt.log.debug('Calling Adapt.init'); +Adapt.data.on('ready', function triggerInit() { + Adapt.log.debug('Calling Adapt.init'); - Adapt.init(); + Adapt.init(); - Adapt.off('adaptCollection:dataLoaded courseModel:dataLoaded'); - }).init(); - -}); + Adapt.off('adaptCollection:dataLoaded courseModel:dataLoaded'); +}).init(); diff --git a/src/core/js/views/questionView.js b/src/core/js/views/questionView.js index 1d7df1e85..8fa8629a3 100644 --- a/src/core/js/views/questionView.js +++ b/src/core/js/views/questionView.js @@ -6,8 +6,6 @@ define([ 'core/js/enums/buttonStateEnum' ], function(Adapt, ComponentView, ButtonsView, QuestionModel, BUTTON_STATE) { - const useQuestionModelOnly = false; - class QuestionView extends ComponentView { className() { @@ -371,9 +369,6 @@ define([ QuestionView._isQuestionType = true; - // allows us to turn on and off the questionView style and use the separated questionModel+questionView style only - if (useQuestionModelOnly) return QuestionView; - /* BACKWARDS COMPATIBILITY SECTION * This section below is only for compatibility between the separated questionView+questionModel and the old questionView * Remove this section in when all components use questionModel and there is no need to have model behaviour in the questionView