tao-test/app/taoQtiItem/views/js/qtiCreator/widgets/Widget.js

366 lines
14 KiB
JavaScript
Raw Normal View History

2022-08-29 20:14:13 +02:00
/*
* 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;
});