406 lines
15 KiB
JavaScript
406 lines
15 KiB
JavaScript
|
/*
|
||
|
* 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',
|
||
|
'i18n',
|
||
|
'jquery',
|
||
|
'core/promise',
|
||
|
'util/url',
|
||
|
'taoQtiItem/qtiCreator/widgets/Widget',
|
||
|
'taoQtiItem/qtiCreator/widgets/item/states/states',
|
||
|
'taoQtiItem/qtiItem/core/Element',
|
||
|
'taoQtiItem/qtiCreator/helper/creatorRenderer',
|
||
|
'taoQtiItem/qtiCreator/model/helper/container',
|
||
|
'taoQtiItem/qtiCreator/editor/gridEditor/content',
|
||
|
'taoQtiItem/qtiCreator/helper/xmlRenderer',
|
||
|
'taoQtiItem/qtiCreator/helper/devTools',
|
||
|
'taoQtiItem/qtiCreator/widgets/static/text/Widget',
|
||
|
'taoQtiItem/qtiItem/helper/xmlNsHandler',
|
||
|
'taoQtiItem/qtiCreator/editor/jquery.gridEditor'
|
||
|
], function (
|
||
|
_,
|
||
|
__,
|
||
|
$,
|
||
|
Promise,
|
||
|
urlUtil,
|
||
|
Widget,
|
||
|
states,
|
||
|
Element,
|
||
|
creatorRenderer,
|
||
|
containerHelper,
|
||
|
contentHelper,
|
||
|
xmlRenderer,
|
||
|
devTools,
|
||
|
TextWidget,
|
||
|
xmlNsHandler
|
||
|
) {
|
||
|
'use strict';
|
||
|
|
||
|
const ItemWidget = Widget.clone();
|
||
|
|
||
|
const _detachElements = function (container, elements) {
|
||
|
const containerElements = {};
|
||
|
_.each(elements, function (elementSerial) {
|
||
|
containerElements[elementSerial] = container.elements[elementSerial];
|
||
|
delete container.elements[elementSerial];
|
||
|
});
|
||
|
return containerElements;
|
||
|
};
|
||
|
|
||
|
ItemWidget.initCreator = function (config) {
|
||
|
this.registerStates(states);
|
||
|
|
||
|
Widget.initCreator.call(this);
|
||
|
|
||
|
if (!config || !config.uri) {
|
||
|
throw new Error('missing required config parameter uri in item widget initialization');
|
||
|
}
|
||
|
|
||
|
this.saveItemUrl = config.saveItemUrl;
|
||
|
|
||
|
this.renderer = config.renderer;
|
||
|
|
||
|
this.itemUri = config.uri;
|
||
|
|
||
|
if (config.perInteractionRp) {
|
||
|
xmlRenderer.setProvider('perInteractionRP');
|
||
|
}
|
||
|
|
||
|
//this.initUiComponents();
|
||
|
|
||
|
return new Promise(resolve => {
|
||
|
this.initTextWidgets(() => {
|
||
|
//when the text widgets are ready:
|
||
|
this.initGridEditor();
|
||
|
|
||
|
//active debugger
|
||
|
this.debug({
|
||
|
state: false,
|
||
|
xml: false
|
||
|
});
|
||
|
|
||
|
resolve();
|
||
|
});
|
||
|
});
|
||
|
};
|
||
|
|
||
|
ItemWidget.buildContainer = function () {
|
||
|
this.$container = this.$original;
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* Save the item by sending the XML in a POST request to the server
|
||
|
* @TODO saving mechanism should be independent, ie. moved into the itemCreator, in order to configure endpoint, etc.
|
||
|
*
|
||
|
* @returns {Promise} that wraps the request
|
||
|
*/
|
||
|
ItemWidget.save = function () {
|
||
|
return new Promise((resolve, reject) => {
|
||
|
// transform application errors into object Error in order to make them displayable
|
||
|
function rejectError(err) {
|
||
|
if (err.type === 'Error') {
|
||
|
err = new Error(__('The item has not been saved!') + (err.message ? `\n${err.message}` : ''));
|
||
|
}
|
||
|
reject(err);
|
||
|
}
|
||
|
|
||
|
const xml = xmlNsHandler.restoreNs(xmlRenderer.render(this.element), this.element.getNamespaces());
|
||
|
|
||
|
//@todo : remove this hotfix : prevent unsupported custom interaction to be saved
|
||
|
if (hasUnsupportedInteraction(xml)) {
|
||
|
return reject(
|
||
|
new Error(__('The item cannot be saved because it contains an unsupported custom interaction.'))
|
||
|
);
|
||
|
}
|
||
|
|
||
|
$.ajax({
|
||
|
url: urlUtil.build(this.saveItemUrl, { uri: this.itemUri }),
|
||
|
type: 'POST',
|
||
|
contentType: 'text/xml',
|
||
|
dataType: 'json',
|
||
|
data: xml
|
||
|
})
|
||
|
.done(function (data) {
|
||
|
if (!data || data.success) {
|
||
|
resolve(data);
|
||
|
} else {
|
||
|
rejectError(data);
|
||
|
}
|
||
|
})
|
||
|
.fail(rejectError);
|
||
|
});
|
||
|
};
|
||
|
|
||
|
ItemWidget.initUiComponents = function () {
|
||
|
const element = this.element,
|
||
|
$saveBtn = $('#save-trigger');
|
||
|
|
||
|
//listen to invalid states:
|
||
|
this.on(
|
||
|
'metaChange',
|
||
|
function (data) {
|
||
|
if (data.element.getSerial() === element.getSerial() && data.key === 'invalid') {
|
||
|
const invalid = element.data('invalid');
|
||
|
if (_.size(invalid)) {
|
||
|
$saveBtn.addClass('disabled');
|
||
|
} else {
|
||
|
$saveBtn.removeClass('disabled');
|
||
|
}
|
||
|
}
|
||
|
},
|
||
|
true
|
||
|
);
|
||
|
};
|
||
|
|
||
|
ItemWidget.initGridEditor = function () {
|
||
|
const _this = this,
|
||
|
item = this.element,
|
||
|
$itemBody = this.$container.find('.qti-itemBody'),
|
||
|
$itemEditorPanel = $('#item-editor-panel');
|
||
|
|
||
|
$itemBody.gridEditor();
|
||
|
$itemBody.gridEditor('resizable');
|
||
|
$itemBody.gridEditor('addInsertables', $('.tool-list > [data-qti-class]:not(.disabled)'), {
|
||
|
helper: function () {
|
||
|
return $(this).find('.icon').clone().addClass('dragging');
|
||
|
}
|
||
|
});
|
||
|
|
||
|
$itemBody
|
||
|
.on('beforedragoverstart.gridEdit', function () {
|
||
|
$itemEditorPanel.addClass('dragging');
|
||
|
$itemBody.removeClass('hoverable').addClass('inserting');
|
||
|
})
|
||
|
.on('dragoverstop.gridEdit', function () {
|
||
|
$itemEditorPanel.removeClass('dragging');
|
||
|
$itemBody.addClass('hoverable').removeClass('inserting');
|
||
|
})
|
||
|
.on('dropped.gridEdit.insertable', function (e, qtiClass, $placeholder) {
|
||
|
//a new qti element has been added: update the model + render
|
||
|
$placeholder.removeAttr('id'); //prevent it from being deleted
|
||
|
|
||
|
if (qtiClass === 'rubricBlock') {
|
||
|
//qti strange exception: a rubricBlock must be the first child of itemBody, nothing else...
|
||
|
//so in this specific case, consider the whole row as the rubricBlock
|
||
|
//by the way, in our grid system, rubricBlock can only have a width of col-12
|
||
|
$placeholder = $placeholder.parent('.col-12').parent('.grid-row');
|
||
|
}
|
||
|
|
||
|
$placeholder.addClass('widget-box'); //required for it to be considered as a widget during container serialization
|
||
|
$placeholder.attr({
|
||
|
'data-new': true,
|
||
|
'data-qti-class': qtiClass
|
||
|
}); //add data attribute to get the dom ready to be replaced by rendering
|
||
|
|
||
|
const $widget = $placeholder.parent().closest('.widget-box, .qti-item');
|
||
|
const $editable = $placeholder.closest('[data-html-editable], .qti-itemBody');
|
||
|
const itemWidget = $widget.data('widget');
|
||
|
const element = itemWidget.element;
|
||
|
const container = Element.isA(element, '_container') ? element : element.getBody();
|
||
|
|
||
|
if (element.attr('dir') === 'rtl') {
|
||
|
// add dir='rtl' to new div.grid-row
|
||
|
$placeholder.parent('.col-12').parent('.grid-row').attr('dir', 'rtl');
|
||
|
}
|
||
|
|
||
|
if (!element || !$editable.length) {
|
||
|
throw new Error('cannot create new element');
|
||
|
}
|
||
|
|
||
|
containerHelper.createElements(container, contentHelper.getContent($editable), function (newElts) {
|
||
|
creatorRenderer.get().load(function () {
|
||
|
_.forEach(newElts, elt => {
|
||
|
let $widgetNewElem, widget;
|
||
|
const $colParent = $placeholder.parent();
|
||
|
|
||
|
elt.setRenderer(this);
|
||
|
|
||
|
if (Element.isA(elt, '_container')) {
|
||
|
$colParent.empty(); //clear the col content, and leave an empty text field
|
||
|
$colParent.html(elt.render());
|
||
|
widget = _this.initTextWidget(elt, $colParent);
|
||
|
$widgetNewElem = widget.$container;
|
||
|
} else {
|
||
|
elt.render($placeholder);
|
||
|
|
||
|
//TODO resolve the promise it returns
|
||
|
elt.postRender(itemWidget.options);
|
||
|
widget = elt.data('widget');
|
||
|
if (Element.isA(elt, 'blockInteraction')) {
|
||
|
$widgetNewElem = widget.$container;
|
||
|
} else {
|
||
|
//leave the container in place
|
||
|
$widgetNewElem = widget.$original;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
//inform height modification
|
||
|
$widgetNewElem.trigger('contentChange.gridEdit');
|
||
|
$widgetNewElem.trigger('resize.gridEdit');
|
||
|
|
||
|
//active it right away:
|
||
|
if (Element.isA(elt, 'interaction')) {
|
||
|
widget.changeState('question');
|
||
|
} else {
|
||
|
widget.changeState('active');
|
||
|
}
|
||
|
});
|
||
|
}, this.getUsedClasses());
|
||
|
});
|
||
|
})
|
||
|
.on('resizestop.gridEdit', function () {
|
||
|
item.body($itemBody.gridEditor('getContent'));
|
||
|
});
|
||
|
};
|
||
|
|
||
|
ItemWidget.initTextWidgets = function (callback) {
|
||
|
const item = this.element,
|
||
|
$originalContainer = this.$container,
|
||
|
subContainers = [];
|
||
|
let i = 1;
|
||
|
|
||
|
callback = callback || _.noop;
|
||
|
|
||
|
//temporarily tag col that need to be transformed into
|
||
|
$originalContainer.find('.qti-itemBody > .grid-row').each(function () {
|
||
|
const $row = $(this);
|
||
|
|
||
|
if (!$row.hasClass('widget-box')) {
|
||
|
//not a rubricBlock
|
||
|
$row.children().each(function () {
|
||
|
const $col = $(this);
|
||
|
let isTextBlock = false;
|
||
|
|
||
|
$col.contents().each(function () {
|
||
|
if (this.nodeType === 3 && this.nodeValue && this.nodeValue.trim()) {
|
||
|
isTextBlock = true;
|
||
|
return false;
|
||
|
}
|
||
|
});
|
||
|
|
||
|
const $widget = $col.children();
|
||
|
|
||
|
if ($widget.length > 1 || !$widget.hasClass('widget-blockInteraction')) {
|
||
|
//not an immediate qti element
|
||
|
if ($widget.hasClass('colrow')) {
|
||
|
$widget.each(function () {
|
||
|
const $subElement = $(this);
|
||
|
const $subWidget = $subElement.children();
|
||
|
if ($subWidget.length > 1 || !$subWidget.hasClass('widget-blockInteraction')) {
|
||
|
$subElement.attr('data-text-block-id', `text-block-${i}`);
|
||
|
i++;
|
||
|
}
|
||
|
});
|
||
|
} else if ($widget.find('.widget-blockInteraction').length === 0) {
|
||
|
isTextBlock = true;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (isTextBlock) {
|
||
|
$col.attr('data-text-block-id', `text-block-${i}`);
|
||
|
i++;
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
});
|
||
|
|
||
|
//clone the container to create the new container model:
|
||
|
const $clonedContainer = $originalContainer.clone();
|
||
|
$clonedContainer.find('.qti-itemBody > .grid-row [data-text-block-id]').each(function () {
|
||
|
const $originalTextBlock = $(this),
|
||
|
textBlockId = $originalTextBlock.data('text-block-id'),
|
||
|
$subContainer = $originalTextBlock.clone(),
|
||
|
subContainerElements = contentHelper.serializeElements($subContainer),
|
||
|
subContainerBody = $subContainer.html(); //get serialized body
|
||
|
|
||
|
$originalTextBlock.removeAttr('data-text-block-id').html('{{_container:new}}');
|
||
|
|
||
|
subContainers.push({
|
||
|
body: subContainerBody,
|
||
|
elements: subContainerElements,
|
||
|
$original: $originalContainer
|
||
|
.find(`[data-text-block-id="${textBlockId}"]`)
|
||
|
.removeAttr('data-text-block-id')
|
||
|
});
|
||
|
});
|
||
|
|
||
|
//create new container model with the created sub containers
|
||
|
contentHelper.serializeElements($clonedContainer);
|
||
|
|
||
|
const serializedItemBody = $clonedContainer.find('.qti-itemBody').html(),
|
||
|
itemBody = item.getBody();
|
||
|
|
||
|
if (subContainers.length) {
|
||
|
containerHelper.createElements(itemBody, serializedItemBody, newElts => {
|
||
|
if (_.size(newElts) !== subContainers.length) {
|
||
|
throw new Error('number of sub-containers mismatch');
|
||
|
} else {
|
||
|
_.each(newElts, container => {
|
||
|
const containerData = subContainers.shift(); //get data in order
|
||
|
const containerElements = _detachElements(itemBody, containerData.elements);
|
||
|
|
||
|
container.setElements(containerElements, containerData.body);
|
||
|
this.initTextWidget(container, containerData.$original);
|
||
|
});
|
||
|
|
||
|
_.defer(function () {
|
||
|
callback.call(this);
|
||
|
});
|
||
|
}
|
||
|
});
|
||
|
} else {
|
||
|
callback.call(this);
|
||
|
}
|
||
|
};
|
||
|
|
||
|
ItemWidget.initTextWidget = function (container, $col) {
|
||
|
return TextWidget.build(container, $col, this.renderer.getOption('textOptionForm'), {});
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* Enable debugging
|
||
|
*
|
||
|
* @param {Object} [options]
|
||
|
* @param {Boolean} [options.state = false] - log state change in console
|
||
|
* @param {Boolean} [options.xml = false] - real-time qti xml display under the creator
|
||
|
*/
|
||
|
ItemWidget.debug = function (options) {
|
||
|
options = options || {};
|
||
|
|
||
|
if (options.state) {
|
||
|
devTools.listenStateChange();
|
||
|
}
|
||
|
|
||
|
if (options.xml) {
|
||
|
const $code = $('<code>', { class: 'language-markup' }),
|
||
|
$pre = $('<pre>', { class: 'line-numbers' }).append($code);
|
||
|
|
||
|
$('#item-editor-wrapper').append($pre);
|
||
|
devTools.liveXmlPreview(this.element, $code);
|
||
|
}
|
||
|
};
|
||
|
|
||
|
function hasUnsupportedInteraction(xml) {
|
||
|
const $qti = $(xml);
|
||
|
return $qti.find('div.qti-interaction.qti-customInteraction[data-serial]').length > 0;
|
||
|
}
|
||
|
|
||
|
return ItemWidget;
|
||
|
});
|