@oat-sa/tao-item-runner-qti
Version:
TAO QTI Item Runner modules
709 lines (626 loc) • 24 kB
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) 2014-2023 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT);
*
*/
/**
* @author Bertrand Chevrier <bertrand@taotesting.com>
*/
import $ from 'jquery';
import _ from 'lodash';
import __ from 'i18n';
import module from 'module';
import 'core/mouseEvent';
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';
let 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
let activeDrop = null;
/**
* Global variable to count number of choice usages:
* @type {object}
*/
const _choiceUsages = {};
/**
* This options enables to support old items created with the wrong
* direction in the directedpairs.
*
* @deprecated
*/
const 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
*/
const _isMatchable = function (element) {
let matchable = false;
if (element) {
const matchMax = element.data('max') || 0;
const 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
*/
const _shapesSelectable = function _shapesSelectable(interaction) {
const tooltip = __('Select the area to add an image');
//update the shape state
_.forEach(interaction.getChoices(), function (choice) {
const 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
*/
const _shapesUnSelectable = function _shapesUnSelectable(interaction) {
_.forEach(interaction.getChoices(), function (choice) {
const 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
*/
const _paperUnSelect = function _paperUnSelect(interaction) {
const $container = containerHelper.get(interaction);
const $gapImages = $('ul > li', $container);
const 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
*/
const _setChoice = function _setChoice(interaction, $choice) {
const choiceSerial = $choice.data('serial');
const choice = interaction.getGapImg(choiceSerial);
let matchMax;
let 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) {
$choice.addClass('disabled');
$choice.removeClass('selectable');
$choice.find('img').attr('draggable', 'false'); //prevent native drag of image (Chrome, mouse)
}
}
};
/**
* Unset a choice and unmark as disabled
* @private
* @param {Object} interaction
* @param {JQuery Element} $choice
*/
const _unsetChoice = function _unsetChoice(interaction, $choice) {
const choiceSerial = $choice.data('serial');
_choiceUsages[choiceSerial]--;
$choice.removeClass('disabled');
$choice.addClass('selectable');
$choice.find('img').removeAttr('draggable');
};
/**
* 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
*/
const _selectShape = function _selectShape(interaction, element, trackResponse) {
let $img, $clone, id, bbox, shapeOffset, activeOffset, matching, currentCount;
//lookup for the active element
const $container = containerHelper.get(interaction);
const $gapList = $('ul', $container);
const $active = $gapList.find('.active:first');
const $imageBox = $('.main-image-box', $container);
const 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() {
$clone.remove();
//extract some coords for positioning
bbox = element.getBBox();
//create an image into the paper and move it to the selected shape
const 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();
const gapFillerImage = gapFiller[2].node;
interact(gapFillerImage).on('tap', function (e) {
const target = e.currentTarget;
const 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
*/
const _renderChoice = function _renderChoice(interaction, choice) {
//create the shape
const 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);
}
}
};
const _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
*/
const _renderGapList = function _renderGapList(interaction, $gapList) {
const $container = containerHelper.get(interaction);
const gapFillersSelector = $gapList.selector + ' li';
let dragOptions;
let scaleX, scaleY;
interact(gapFillersSelector).on('tap', function onClickGapImg(e) {
e.stopPropagation();
e.preventDefault();
toggleActiveGapState($(e.currentTarget));
});
if (isDragAndDropEnabled) {
const touchPatch = interactUtils.touchPatchFactory();
interaction.data('touchPatch', touchPatch);
dragOptions = {
inertia: false,
autoScroll: true,
restrict: {
restriction: '.qti-interaction',
endOnly: false,
elementRect: { top: 0, left: 0, bottom: 1, right: 1 }
}
};
interact(gapFillersSelector + '.selectable')
.draggable(
_.assign({}, dragOptions, {
onstart: function (e) {
const $target = $(e.target);
_setActiveGapState($target);
$target.addClass('dragged');
_iFrameDragFix(gapFillersSelector, e.target);
const scale = interactUtils.calculateScale(e.target);
scaleX = scale[0];
scaleY = scale[1];
touchPatch.onstart();
},
onmove: function (e) {
interactUtils.moveElement(e.target, e.dx / scaleX, e.dy / scaleY);
},
onend: function (e) {
_.defer(() => {
const $target = $(e.target);
_setInactiveGapState($target);
$target.removeClass('dragged');
interactUtils.restoreOriginalPosition($target);
interactUtils.iFrameDragFixOff();
touchPatch.onend();
});
}
})
)
.styleCursor(false)
.actionChecker(touchPatch.actionChecker);
}
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}
*/
const render = function render(interaction) {
const self = this;
return new Promise(function (resolve) {
const $container = containerHelper.get(interaction);
const $gapList = $('ul.source', $container);
const 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 () {
const $img = $(this);
$img.width($img.attr('width') * factor);
$img.height($img.attr('height') * factor);
});
}
},
responsive: $container.hasClass('responsive')
});
//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
*/
const _getRawResponse = function _getRawResponse(interaction) {
const pairs = [];
_.forEach(interaction.getChoices(), function (choice) {
const 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
*/
const setResponse = function (interaction, response) {
const $container = containerHelper.get(interaction);
let responseValues;
if (response && interaction.paper) {
try {
responseValues = pciResponse.unserialize(response, interaction);
} catch (e) {
responseValues = null;
}
if (_.isArray(responseValues)) {
_.forEach(interaction.getChoices(), function (choice) {
const element = interaction.paper.getById(choice.serial);
if (element) {
_.forEach(responseValues, function (pair) {
if (pair.length === 2) {
//backward support of previous order
const responseChoice = isDirectedPairFlipped ? pair[0] : pair[1];
const 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
*/
const 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}
*/
const getResponse = function (interaction) {
const raw = _getRawResponse(interaction);
return pciResponse.serialize(raw, interaction);
};
/**
* Clean interaction destroy
* @param {Object} interaction
*/
const destroy = function destroy(interaction) {
if (interaction.paper) {
const $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();
if (interaction.data('touchPatch')) {
interaction.data('touchPatch').destroy();
interaction.removeData('touchPatch');
}
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
*/
const 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
*/
const getState = function getState(interaction) {
const state = {};
const 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
};