/* * 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-2021 (original work) Open Assessment Technologies SA ; * */ define([ 'lodash', 'jquery', 'core/promise', 'taoQtiItem/qtiItem/core/Element', 'taoQtiItem/qtiCreator/model/helper/invalidator', 'core/logger' ], function (_, $, Promise, Element, invalidator, loggerFactory) { 'use strict'; const _pushState = function (widget, stateName) { const currentState = new widget.registeredStates[stateName](widget); widget.stateStack.push(currentState); currentState.init(); }; const _popState = function (widget) { const state = widget.stateStack.pop(); if (state) { state.exit(); } }; const createEventName = (widget, name, ns) => { const eventName = `${name}.qti-widget.${widget.serial}`; if (ns) { return `${eventName}.${ns}`; } return eventName; }; /** * Create a logger */ const logger = loggerFactory('taoQtiItem/qtiCreator/widget'); const Widget = { /** * Intialize qti element creator widget * * @param {Object} element - standard qti object * @param {Jquery} $original - tje proginal DOM element of the qti element * @param {JQuery} $form - the property form of the qti element * @param {Object} options * @fires ready.qti-widget after it is executed * @returns {Object} The initialized widget */ init: function (element, $original, $form, options) { if (element instanceof Element) { options = options || {}; this.element = element; this.serial = element.getSerial(); this.$original = $original; this.$form = $form; this.stateStack = []; this.registeredStates = {}; //build container from origin element this.buildContainer(); //attach the widget to widget $container and element: this.$original.data('widget', this); this.$container.data('widget', this); this.element.data('widget', this); //clean old referenced event this.offEvents(); //not sure if still required after state definition //pass the options to the initCreator for custom options usage _.each(this.getRequiredOptions(), function (opt) { if (!options[opt]) { throw new Error(`missing required option for image creator : ${opt}`); } }); this.options = options; Promise.resolve(this.initCreator(options)).then(() => { //communicate the widget readiness if (_.isFunction(this.options.ready)) { this.options.ready.call(this, this); } this.$container.trigger('ready.qti-widget', [this]); }); //init state after creator init if (this.options.state) { this.changeState(this.options.state); } else { this.changeState('sleep'); } } else { throw new Error('element is not a QTI Element'); } return this; }, getAreaBroker: function getAreaBroker() { const element = this.element, renderer = element.getRenderer(); if (renderer) { return renderer.getAreaBroker(); } }, getCreatorContext: function getCreatorContext() { const element = this.element, renderer = element.getRenderer(); if (renderer) { return renderer.getCreatorContext(); } }, getRequiredOptions: function () { return []; }, buildContainer: function () { throw new Error('method buildContainer must be implemented'); }, build: function (element, $container, $form, options) { return this.clone().init(element, $container, $form, options); }, clone: function () { return _.clone(this); }, initCreator: function () { //prepare all common actions, event handlers and dom for every state of the widget const $interaction = this.$container.find('.qti-interaction'); const serial = $interaction.data('serial'); this.$container.on('resize.itemResizer', function () { $(window).trigger(`resize.qti-widget.${serial}`); }); }, getCurrentState: function () { return _.last(this.stateStack); }, /** * Very important method: * It changes the state of the widget by checking the relation between * the target and the current states. * * @param {string} stateName * @returns {object} this */ changeState: function (stateName) { let state, superStateName, currentState = this.getCurrentState(), exitedStates, enteredStates, i; logger.info(`changing state of ${this.serial}: ${(currentState || {}).name} => ${stateName}`); if (this.registeredStates[stateName]) { state = new this.registeredStates[stateName](); } else { throw new Error(`unknown target state : ${stateName}`); } if (currentState) { // hide widget tooltips when interaction leaves response mapping ('map') state: if (currentState.name === 'map' && state.name !== 'map') { this.$container.find('[data-has-tooltip]').each(function (j, el) { $(el).data('$tooltip').hide(); }); } if (currentState.name === state.name) { return this; } else if (_.indexOf(state.superState, currentState.name) >= 0) { //initialize super states in reverse order: for (i = _.indexOf(state.superState, currentState.name) - 1; i >= 0; i--) { superStateName = state.superState[i]; _pushState(this, superStateName); } } else if (_.indexOf(currentState.superState, state.name) >= 0) { //just exit as much state as needed to get to it: for (i = 0; i <= _.indexOf(currentState.superState, state.name); i++) { _popState(this); } return this; } else { //first, exit the current state _popState(this); //then, exit super states in order: exitedStates = _.difference(currentState.superState, state.superState); _.each(exitedStates, () => { _popState(this); }); //finally, init super states in reverse order: enteredStates = _.difference(state.superState, currentState.superState); _.eachRight(enteredStates, _superStateName => { _pushState(this, _superStateName); }); } } else { _.eachRight(state.superState, _superStateName => { _pushState(this, _superStateName); }); } _pushState(this, stateName); return this; }, registerState: function (name, State) { if (name && State) { this.registeredStates[name] = State; } else { throw new Error('missing required arguments in state registration'); } }, registerStates: function (states) { _.forIn(states, (State, name) => { this.registerState(name, State); }); }, afterStateInit: function (callback, ns) { $(document).on(createEventName(this, 'afterStateInit', ns), callback); }, beforeStateInit: function (callback, ns) { $(document).on(createEventName(this, 'beforeStateInit', ns), callback); }, afterStateExit: function (callback, ns) { $(document).on(createEventName(this, 'afterStateExit', ns), callback); }, beforeStateExit: function (callback, ns) { $(document).on(createEventName(this, 'beforeStateExit', ns), callback); }, offEvents: function (ns) { $(document).off(createEventName(this, '', ns)); this.$container.off('resize.itemResizer'); }, destroy: function () { logger.info(`destroying widget ${this.serial}`); //to call exit method and clean up listeners this.changeState('sleep'); //remove editable widgets this.$container.find('[data-edit]').remove(); $(`[data-widget-component=${this.serial}]`).remove(); //clean old referenced event this.offEvents(); }, rebuild: function (options) { let element, postRenderOpts, $container, renderer; options = options || {}; element = this.element; postRenderOpts = {}; if (_.isFunction(options.ready)) { postRenderOpts.ready = options.ready; } $container = null; if (options.context && options.context.length) { //if the context option is provided, the function will fetch the widget container that in this context //mandatory for detached of duplicated DOM element (e.g. ckEditor) $container = options.context.find(`.widget-box[data-serial=${element.serial}]`); } else if (this.$container.length && $.contains(document, this.$container[0])) { //if the container exist and is NOT detached $container = this.$container; } else { //otherwise use less performance efficient selector $container = $(`.widget-box[data-serial=${element.serial}]`); } //once required data ref has been set, destroy it: this.destroy(); //we assume that the element still has its renderer set, check renderer: renderer = element.getRenderer(); if (renderer && renderer.isRenderer) { if (renderer.name === 'creatorRenderer') { element.render($container); element.postRender(postRenderOpts); return element.data('widget'); } else { throw new Error('The renderer is no longer the creatorRenderer'); } } else { throw new Error('No renderer found to rebuild the widget'); } }, refresh: function () { const currentState = this.getCurrentState().name; this.rebuild({ ready: function (widget) { widget.changeState(currentState); } }); }, //assign an event listener that lives with the state on: function (qtiElementEventName, callback, live) { const eventNames = qtiElementEventName.replace(/\s+/g, ' ').split(' '), $document = $(document); _.each(eventNames, eventName => { const eventNameToken = [eventName, 'qti-widget', this.serial]; if (!live) { eventNameToken.push(this.getCurrentState().name); } //bind each individual event listener to the document $document.on(eventNameToken.join('.'), (e, data) => { callback.call(this, data); }); }); return this; //for chaining }, /** * Get / Set the validation state * @param {String} [what] - key to identify the validation * @param {Boolean} [valid] - false to invalidate * @param {String} [why] - message * @returns {boolean} - if what return isValid */ isValid: function (what, valid, why) { const element = this.element; if (typeof what === 'undefined') { //get return invalidator.isValid(element); } else if (valid) { invalidator.valid(element, what); } else { invalidator.invalid(element, what, why, this.getCurrentState().name); } } }; return Widget; });