UNPKG

@oat-sa/tao-item-runner-qti

Version:
846 lines (750 loc) 30.5 kB
/* * 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 placedFillerTpl from 'taoQtiItem/qtiCommonRenderer/tpl/choices/placedGapImg.graphicGapMatch'; 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 gapfiller (image) usages: * @type {object} */ const _gapFillerUsages = {}; const dragOptions = { inertia: false, autoScroll: true, restrict: { restriction: '.qti-interaction', endOnly: false, elementRect: { top: 0, left: 0, bottom: 1, right: 1 } } }; /** * 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 $container = containerHelper.get(interaction); const tooltip = __('Select the area to add an image'); _.forEach(interaction.getChoices(), function (choice) { const element = interaction.paper.getById(choice.serial); if (_isMatchable(element)) { element.selectable = true; graphic.updateElementState(element, 'selectable'); graphic.updateTitle(element, tooltip); } else { graphic.updateTitle(element, ''); } }); const $placedFillersContainer = $('.placed-fillers', $container); $placedFillersContainer.addClass('selectable'); _.forEach(interaction.placedFillers, function ($placedFiller) { const element = interaction.paper.getById($placedFiller.attr('data-shape-id')); $placedFiller.attr('title', _isMatchable(element) ? tooltip : ''); }); }; /** * Makes all the shapes UNselectable * @private * @param {Object} interaction */ const _shapesUnSelectable = function _shapesUnSelectable(interaction) { const $container = containerHelper.get(interaction); _.forEach(interaction.getChoices(), function (choice) { const element = interaction.paper.getById(choice.serial); if (element) { element.selectable = false; graphic.updateElementState(element, 'basic'); graphic.updateTitle(element, __('Select an image first')); } }); const $placedFillersContainer = $('.placed-fillers', $container); $placedFillersContainer.removeClass('selectable'); _.forEach(interaction.placedFillers, function ($placedFiller) { $placedFiller.attr('title', __('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 $gapList = $('ul.source', $container); const $gapFillers = $gapList.find('li', $container); const bgImage = interaction.paper.getById('bg-image-' + interaction.serial); if (bgImage) { interact(bgImage.node).on('tap', function () { _shapesUnSelectable(interaction); $gapFillers.removeClass('active'); }); } }; /** * Sets a gapfiller (= image) and marks as disabled if at max * @private * @param {Object} interaction * @param {JQuery Element} $gapFiller */ const _setGapFiller = function _setGapFiller(interaction, $gapFiller) { const gapFillerSerial = $gapFiller.data('serial'); const gapFiller = interaction.getGapImg(gapFillerSerial); let matchMax; let usages; if (!_gapFillerUsages[gapFillerSerial]) { _gapFillerUsages[gapFillerSerial] = 0; } _gapFillerUsages[gapFillerSerial]++; // disable gapfiller if maxium usage reached if (!interaction.responseMappingMode && gapFiller.attr('matchMax')) { matchMax = +gapFiller.attr('matchMax'); usages = +_gapFillerUsages[gapFillerSerial]; // note: if matchMax is 0, then test taker is allowed unlimited usage of that gapfiller if (matchMax !== 0 && matchMax <= usages) { $gapFiller.addClass('disabled'); $gapFiller.removeClass('selectable'); $gapFiller.find('img').attr('draggable', 'false'); //prevent native drag of image (Chrome, mouse) } } }; /** * * @param {Object} interaction * @param {JQuery} $fromGapFiller * @param {Raphael.Element|JQuery} toElement * @param {Function?} endCallback */ const _animateMoveGapFiller = function _animateMoveGapFiller(interaction, $fromGapFiller, toElement, endCallback) { const $container = containerHelper.get(interaction); const $imageBox = $('.main-image-box', $container); const $clone = interactUtils.animateMoveElement({ $appendTo: $imageBox, $fromElement: $fromGapFiller.find('img'), $toElement: toElement.paper ? $(toElement.node) : toElement, endCallback }); $clone.css({ opacity: 0.8 }); }; /** * Remove a placed gapfiller (= image), * unmark it as disabled in the source area, * update response * @private * @param {Object} interaction * @param {JQuery Element} $placedFiller * @param {boolean} animate */ const _removePlacedGapFiller = function _removePlacedGapFiller(interaction, $placedFiller, animate = true) { const $container = containerHelper.get(interaction); const placedFillerId = $placedFiller.attr('data-identifier'); const $sourceFiller = $('ul.source', $container).find(`li[data-identifier="${placedFillerId}"]`); const gapFillerSerial = $sourceFiller.data('serial'); const element = interaction.paper.getById($placedFiller.attr('data-shape-id')); if (animate) { _animateMoveGapFiller(interaction, $placedFiller, $sourceFiller); } element.data('matching', _.without(element.data('matching') || [], placedFillerId)); interaction.placedFillers = _.without(interaction.placedFillers, $placedFiller); _gapFillerUsages[gapFillerSerial]--; $placedFiller.remove(); $sourceFiller.removeClass('disabled'); $sourceFiller.addClass('selectable'); $sourceFiller.find('img').removeAttr('draggable'); containerHelper.triggerResponseChangeEvent(interaction); }; const _iFrameDragFix = function _iFrameDragFix(draggableSelectorOrElement, target) { interactUtils.iFrameDragFixOn(function () { if (activeDrop) { interact(activeDrop).fire({ type: 'drop', target: activeDrop, relatedTarget: target }); } interact(draggableSelectorOrElement).fire({ type: 'dragend', target: target }); }); }; /** * Select a shape (= hotspot) (a gap image must be active) * @private * @param {Object} interaction * @param {Raphael.Element} element - the selected shape * @param {JQuery} [gapFillerId = void 0] - gap filler id to add, or find '.active' one * @param {Boolean} [trackResponse = true] - if the selection trigger a response chane */ const _selectShape = function _selectShape(interaction, element, gapFillerId, trackResponse) { let $img, id, bbox, matching, currentCount; //lookup for the active element const $container = containerHelper.get(interaction); const $gapList = $('ul', $container); const $placedFillersContainer = $('.placed-fillers', $container); if (typeof trackResponse === 'undefined') { trackResponse = true; } const $active = gapFillerId ? $gapList.find(`[data-identifier="${gapFillerId}"]`) : $gapList.find('.active:first'); if ($active.length) { //the matching elements are linked to the shape id = $active.attr('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'); _setGapFiller(interaction, $active); const _createPlacedFiller = () => { bbox = element.getBBox(); const scale = interaction.paper.scale; const originalLeft = bbox.x + 8 * (currentCount - 1); const originalTop = bbox.y + 8 * (currentCount - 1); const originalWidth = $img.attr('width'); const originalHeight = $img.attr('height'); const $placedFiller = $( placedFillerTpl({ shapeId: element.id, id, src: $img.attr('src'), originalLeft, originalTop, originalWidth, originalHeight, left: scale * originalLeft, top: scale * originalTop, width: scale * originalWidth, height: scale * originalHeight }) ); $placedFillersContainer.append($placedFiller); interaction.placedFillers.push($placedFiller); containerHelper.triggerResponseChangeEvent(interaction); interact($placedFiller.get(0)).on('tap', function (e) { e.preventDefault(); e.stopPropagation(); if (!$placedFiller.hasClass('dragged')) { if ($gapList.find('.active').length > 0) { // adding a new gapfiller on the hotspot by simulating a click on the underlying shape... interactUtils.tapOn(element.node); } else { // ... or removing the existing gapfiller _removePlacedGapFiller(interaction, $placedFiller, true); } } }); if (isDragAndDropEnabled) { const touchPatch = interaction.data('touchPatch'); let dragScaleX; let dragScaleY; interact($placedFiller.get(0)) .draggable( _.assign({}, dragOptions, { onstart: function (e) { const $target = $(e.target); $target.addClass('dragged'); $gapList.addClass('selectable'); _setActiveGapState(interaction, $target); _iFrameDragFix($placedFiller.get(0), e.target); const dragScale = interactUtils.calculateScale(e.target); dragScaleX = dragScale[0]; dragScaleY = dragScale[1]; touchPatch.onstart(); }, onmove: function (e) { interactUtils.moveElement(e.target, e.dx / dragScaleX, e.dy / dragScaleY); }, onend: function (e) { _.defer(() => { const $target = $(e.target); $target.removeClass('dragged'); $gapList.removeClass('selectable'); _setInactiveGapState(interaction, $target); interactUtils.restoreOriginalPosition($target); interactUtils.iFrameDragFixOff(); touchPatch.onend(); }); } }) ) .styleCursor(false) .actionChecker(touchPatch.actionChecker); } setTimeout(() => { if ($placedFiller.get(0).matches(':hover')) { $placedFiller.addClass('no-hover').on('mouseleave.graphic-gap-match-placed', function () { $placedFiller .off('mouseleave.graphic-gap-match-placed') .removeClass('no-hover') .attr('title', __('Remove')); }); } }, 0); }; // animate unless moving placed filler to another shape, or unless restoring initial response if (gapFillerId || !trackResponse) { _createPlacedFiller(); } else { _animateMoveGapFiller(interaction, $active, element, _createPlacedFiller); } } }; function _setActiveGapState(interaction, $target) { const $container = containerHelper.get(interaction); const $gapList = $('ul', $container); $gapList.children('li').removeClass('active'); if (!$target.hasClass('placed')) { $target.addClass('active'); } _shapesSelectable(interaction); $(document.body).on('click.graphic-gap-match', function (e) { if ( !$container.get(0).contains(e.target) || (!e.target.closest('.qti-gapImg') && !e.target.closest('.main-image-box')) ) { _setInactiveGapState(interaction, $target); } }); } function _setInactiveGapState(interaction, $target) { $target.removeClass('active'); _shapesUnSelectable(interaction); $(document.body).off('click.graphic-gap-match'); } /** * 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) { const $container = containerHelper.get(interaction); //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, touchEffect: false, useCssClass: true, useClipPath: true }) .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 (_canDrop()) { graphic.updateElementState(rElement, 'hover'); activeDrop = rElement.node; } }, ondrop: function () { if (_canDrop()) { let gapFillerId; const $placedFiller = $('.placed-fillers', $container).find('.dragged'); if ($placedFiller.length) { gapFillerId = $placedFiller.attr('data-identifier'); _removePlacedGapFiller(interaction, $placedFiller, false); } graphic.updateElementState(rElement, 'selectable'); handleShapeSelect(gapFillerId); activeDrop = null; } }, ondragleave: function () { if (_canDrop()) { graphic.updateElementState(rElement, 'selectable'); activeDrop = null; } } }); } function handleShapeSelect(gapFillerId) { // check if can make the shape selectable on click if (_isMatchable(rElement) && rElement.selectable === true) { _selectShape(interaction, rElement, gapFillerId); } } function _canDrop() { return _isMatchable(rElement); } }; /** * Render the list of gap fillers * @private * @param {Object} interaction * @param {jQueryElement} $gapList - the list than contains the orderers */ const _renderGapFillersList = function _renderGapFillersList(interaction, $gapList) { const $container = containerHelper.get(interaction); const gapFillersSelector = $gapList.selector + ' li'; let scaleX, scaleY; interact(gapFillersSelector).on('tap', function onClickGapImg(e) { e.stopPropagation(); e.preventDefault(); toggleActiveGapState($(e.currentTarget)); }); if (isDragAndDropEnabled) { const _canDrop = e => e.target.classList.contains('selectable'); interact($gapList.selector).dropzone({ overlap: 0.15, ondragenter: function (e) { if (_canDrop(e)) { $(e.target).addClass('hover'); } }, ondrop: function (e) { if (_canDrop(e)) { $(e.target).removeClass('hover'); const $placedFiller = $('.placed-fillers', $container).find('.dragged'); _removePlacedGapFiller(interaction, $placedFiller, true); } }, ondragleave: function (e) { if (_canDrop(e)) { $(e.target).removeClass('hover'); } } }); const touchPatch = interactUtils.touchPatchFactory(); interaction.data('touchPatch', touchPatch); interact(gapFillersSelector + '.selectable') .draggable( _.assign({}, dragOptions, { onstart: function (e) { const $target = $(e.target); _setActiveGapState(interaction, $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(interaction, $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(interaction, $target); } else { _setActiveGapState(interaction, $target); } } } }; /** * 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 $placedFillersContainer = $('.placed-fillers', $container); const background = interaction.object.attributes; interaction.placedFillers = []; 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 (newWidth, factor, newHeight) { $gapList.css('max-width', newWidth + 'px'); if (factor !== 1) { $gapList.find('img').each(function () { const $img = $(this); $img.width($img.attr('width') * factor); $img.height($img.attr('height') * factor); }); } $placedFillersContainer.css({ width: `${newWidth}px`, height: `${newHeight}px` }); $placedFillersContainer.find('.placed').each(function () { const $div = $(this); const $img = $div.find('img'); $div.css({ top: `${$div.attr('data-top') * factor}px`, left: `${$div.attr('data-left') * factor}px` }); $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 _renderGapFillersList(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, void 0, 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.placedFillers, function ($placedFiller) { interactUtils.tapOn($placedFiller); }); }; /** * 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); $(document.body).off('click.graphic-gap-match'); $(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 };