@oat-sa/tao-item-runner-qti
Version:
TAO QTI Item Runner modules
802 lines (695 loc) • 28.2 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) 2015-2023 (original work) Open Assessment Technologies SA ;
*/
/**
* @author Bertrand Chevrier <bertrand@taotesting.com>
*/
import $ from 'jquery';
import _ from 'lodash';
import raphael from 'raphael';
import scaleRaphael from 'scale.raphael';
import gstyle from 'taoQtiItem/qtiCommonRenderer/renderers/graphic-style';
//maps the QTI shapes to Raphael shapes
const shapeMap = {
default: 'rect',
poly: 'path'
};
//length constraints to validate coords
const coordsValidator = {
rect: 4,
ellipse: 4,
circle: 3,
poly: 6,
default: 0
};
//transform the coords from the QTI system to Raphael system
const qti2raphCoordsMapper = {
/**
* Rectangle coordinate mapper: from left-x,top-y,right-x-bottom-y to x,y,w,h
* @param {Array} coords - QTI coords
* @returns {Array} raphael coords
*/
rect: function (coords) {
return [coords[0], coords[1], coords[2] - coords[0], coords[3] - coords[1]];
},
/**
* Creates the coords for a default shape (a rectangle that covers all the paper)
* @param {Raphael.Paper} paper - the paper
* @returns {Array} raphael coords
*/
default: function (paper) {
return [0, 0, paper.width, paper.height];
},
/**
* polygone coordinate mapper: from x1,y1,...,xn,yn to SVG path format
* @param {Array} coords - QTI coords
* @returns {Array} path desc
*/
poly: function (coords) {
let a;
const size = coords.length;
// autoClose if needed
if (coords[0] !== coords[size - 2] && coords[1] !== coords[size - 1]) {
coords.push(coords[0]);
coords.push(coords[1]);
}
// move to first point
coords[0] = 'M' + coords[0];
for (a = 1; a < size; a++) {
if (a % 2 === 0) {
coords[a] = 'L' + coords[a];
}
}
return [coords.join(' ')];
}
};
//transform the coords from a raphael shape to the QTI system
const raph2qtiCoordsMapper = {
/**
* Rectangle coordinate mapper: from x,y,w,h to left-x,top-y,right-x-bottom-y
* @param {Object} attr - Raphael Element's attributes
* @returns {Array} qti based coords
*/
rect: function (attr) {
return [attr.x, attr.y, attr.x + attr.width, attr.y + attr.height];
},
/**
* Circle coordinate mapper
* @param {Object} attr - Raphael Element's attributes
* @returns {Array} qti based coords
*/
circle: function (attr) {
return [attr.cx, attr.cy, attr.r];
},
/**
* Ellispe coordinate mapper
* @param {Object} attr - Raphael Element's attributes
* @returns {Array} qti based coords
*/
ellipse: function (attr) {
return [attr.cx, attr.cy, attr.rx, attr.ry];
},
/**
* Get the coords for a default shape (a rectangle that covers all the paper)
* @param {Object} attr - Raphael Element's attributes
* @returns {Array} qti based coords
*/
default: function (attr) {
return this.rect(attr);
},
/**
* polygone coordinate mapper: from SVG path (available as segments) to x1,y1,...,xn,yn format
* @param {Raphael.Paper} paper - the paper
* @returns {Array} raphael coords
*/
path: function (attr) {
const poly = [];
let i;
if (_.isArray(attr.path)) {
for (i = 1; i < attr.path.length; i++) {
if (attr.path[i].length === 3) {
poly.push(attr.path[i][1]);
poly.push(attr.path[i][2]);
}
}
}
return poly;
}
};
/**
* Graphic interaction helper
* @exports qtiCommonRenderer/helpers/Graphic
*/
const GraphicHelper = {
/**
* Raw access to the styles
* @type {Object}
*/
_style: gstyle,
/**
* Apply the style defined by name to the element
* @param {Raphael.Element} element - the element to change the state
* @param {String} state - the name of the state (from states) to switch to
*/
setStyle: function (element, name) {
if (element && gstyle[name]) {
element.attr(gstyle[name]);
}
},
/**
* Create a Raphael paper with a bg image, that is width responsive
* @param {String} id - the id of the DOM element that will contain the paper
* @param {String} serial - the interaction unique indentifier
* @param {Object} options - the paper parameters
* @param {String} options.img - the url of the background image
* @param {jQueryElement} [options.container] - the parent of the paper element (got the closest parent by default)
* @param {Boolean} [options.responsive] - scale to container
* @param {Number} [options.width] - the paper width
* @param {Number} [options.height] - the paper height
* @param {String} [options.imgId] - an identifier for the image element
* @param {Function} [options.done] - executed once the image is loaded
* @returns {Raphael.Paper} the paper
*/
responsivePaper: function (id, serial, options) {
const $container = options.container || $('#' + id).parent();
const $editor = $('.image-editor', $container);
const $body = $container.closest('.qti-itemBody');
const resizer = _.throttle(resizePaper, 10);
const imgWidth = options.width || $container.innerWidth();
const imgHeight = options.height || $container.innerHeight();
const paper = scaleRaphael(id, imgWidth, imgHeight);
const image = paper.image(options.img, 0, 0, imgWidth, imgHeight);
image.id = options.imgId || image.id;
paper.setViewBox(0, 0, imgWidth, imgHeight);
resizer();
//retry to resize once the SVG is loaded
$(image.node).attr('externalResourcesRequired', 'true').on('load', resizer);
if (raphael.type === 'SVG') {
// TODO: move listeners somewhere they can be easily turned off
$(window).on('resize.qti-widget.' + serial, resizer);
// TODO: favor window resize event and deprecate $container resive event (or don't allow $container to be destroyed and rebuilt
$container.on('resize.qti-widget.' + serial, resizer);
$(document).on('customcssloaded.styleeditor', resizer);
} else {
$container.find('.main-image-box').width(imgWidth);
if (typeof options.resize === 'function') {
options.resize(imgWidth, 1);
}
}
/**
* scale the raphael paper
* @private
*/
function resizePaper(e, givenWidth) {
let containerWidth;
if (e) {
e.stopPropagation();
}
const diff = $editor.outerWidth() - $editor.width() + ($container.outerWidth() - $container.width()) + 1;
const maxWidth = $body.width();
if (options.responsive) {
containerWidth = $container.innerWidth();
} else {
containerWidth = $editor.innerWidth();
}
if ((options.responsive && containerWidth > 0) || givenWidth > 0 || containerWidth > maxWidth) {
if (options.responsive) {
if (givenWidth < containerWidth && givenWidth < maxWidth) {
containerWidth = givenWidth - diff;
} else if (containerWidth > maxWidth) {
containerWidth = maxWidth - diff;
} else {
containerWidth -= diff;
}
} else {
if (givenWidth > 0 && givenWidth < maxWidth) {
containerWidth = givenWidth;
} else if (containerWidth > maxWidth) {
containerWidth = maxWidth;
}
}
const factor = containerWidth / imgWidth;
const containerHeight = imgHeight * factor;
if (containerWidth > 0) {
paper.changeSize(containerWidth, containerHeight, false, false);
}
if (typeof options.resize === 'function') {
options.resize(containerWidth, factor);
}
}
$container.trigger('resized.qti-widget');
}
return paper;
},
/**
* Create a new Element into a raphael paper
* @param {Raphael.Paper} paper - the interaction paper
* @param {String} type - the shape type
* @param {String|Array.<Number>} coords - qti coords as a string or an array of number
* @param {Object} [options] - additional creation options
* @param {String} [options.id] - to set the new element id
* @param {String} [options.title] - to set the new element title
* @param {String} [options.style = basic] - to default style
* @param {Boolean} [options.hover = true] - to disable the default hover state
* @param {Boolean} [options.touchEffect = true] - a circle appears on touch
* @param {Boolean} [options.qtiCoords = true] - if the coords are in QTI format
* @returns {Raphael.Element} the created element
*/
createElement: function (paper, type, coords, options) {
const self = this;
let element;
const shaper = shapeMap[type] ? paper[shapeMap[type]] : paper[type];
const shapeCoords = options.qtiCoords !== false ? self.raphaelCoords(paper, type, coords) : coords;
if (typeof shaper === 'function') {
element = shaper.apply(paper, shapeCoords);
if (element) {
if (options.id) {
element.id = options.id;
}
if (options.title) {
element.attr('title', options.title);
}
element.attr(gstyle[options.style || 'basic']).toFront();
//prevent issue in firefox 37
$(element.node).removeAttr('stroke-dasharray');
if (options.hover !== false) {
element.hover(
function () {
if (!element.flashing) {
self.updateElementState(this, 'hover');
}
},
function () {
if (!element.flashing) {
self.updateElementState(
this,
this.active ? 'active' : this.selectable ? 'selectable' : 'basic'
);
}
}
);
}
if (options.touchEffect !== false) {
element.touchstart(function () {
self.createTouchCircle(paper, element.getBBox());
});
}
}
} else {
throw new Error('Unable to find method ' + type + ' on paper');
}
return element;
},
/**
* Create target point
* @param {Raphael.Paper} paper - the paper
* @param {Object} [options]
* @param {Object} [options.id] - and id to identify the target
* @param {Object} [options.point] - the point to add to the paper
* @param {Number} [options.point.x = 0] - point's x coord
* @param {Number} [options.point.y = 0] - point's y coord
* @param {Boolean} [options.hover] = true - the target has an hover effect
* @param {Function} [options.create] - call once created
* @param {Function} [options.remove] - call once removed
*/
createTarget: function createTarget(paper, options) {
options = options || {};
const point = options.point || { x: 0, y: 0 };
const factor = paper.w !== 0 ? paper.width / paper.w : 1;
const hover = typeof options.hover === 'undefined' ? true : !!options.hover;
const baseSize = 18; // this is the base size of the path element to be placed on svg (i.e. the path element crosshair is created to have a size of 18)
const half = baseSize / 2;
const x = point.x - half;
const y = point.y - half;
const targetSize = factor !== 0 ? 2 / factor : 2;
//create the target from a path
const target = paper
.path(gstyle.target.path)
.transform('t' + x + ',' + y + 's' + targetSize)
.attr(gstyle.target)
.attr('title', _('Click again to remove'));
//generate an id if not set in options
if (options.id) {
target.id = options.id;
} else {
let count = 0;
paper.forEach(function (element) {
if (element.data('target')) {
count++;
}
});
target.id = 'target-' + count;
}
const tBBox = target.getBBox();
//create an invisible rect over the target to ensure path selection
const layer = paper
.rect(tBBox.x, tBBox.y, tBBox.width, tBBox.height)
.attr(gstyle.layer)
.click(function () {
const id = target.id;
const p = this.data('point');
if (_.isFunction(options.select)) {
options.select(target, p, this);
}
if (_.isFunction(options.remove)) {
this.remove();
target.remove();
options.remove(id, p);
}
});
if (hover) {
layer.hover(
() => {
if (!target.flashing) {
this.setStyle(target, 'target-hover');
}
},
() => {
if (!target.flashing) {
this.setStyle(target, 'target-success');
}
}
);
}
layer.id = 'layer-' + target.id;
layer.data('point', point);
target.data('target', point);
if (_.isFunction(options.create)) {
options.create(target);
}
return target;
},
/**
* Get the Raphael coordinate from QTI coordinate
* @param {Raphael.Paper} paper - the interaction paper
* @param {String} type - the shape type
* @param {String|Array.<Number>} coords - qti coords as a string or an array of number
* @returns {Array} the arguments array of coordinate to give to the approriate raphael shapre creator
*/
raphaelCoords: function raphaelCoords(paper, type, coords) {
let shapeCoords;
if (_.isString(coords)) {
coords = _.map(coords.split(','), function (coord) {
return parseInt(coord, 10);
});
}
if (!_.isArray(coords) || coords.length < coordsValidator[type]) {
throw new Error('Invalid coords ' + JSON.stringify(coords) + ' for type ' + type);
}
switch (type) {
case 'rect':
shapeCoords = qti2raphCoordsMapper.rect(coords);
break;
case 'default':
shapeCoords = qti2raphCoordsMapper['default'].call(null, paper);
break;
case 'poly':
shapeCoords = qti2raphCoordsMapper.poly(coords);
break;
default:
shapeCoords = coords;
break;
}
return shapeCoords;
},
/**
* Get the QTI coordinates from a Raphael Element
* @param {Raphael.Element} element - the shape to get the coords from
* @param {Raphael.Element} paper - the interaction paper
* @param {number} width - width of background image
* @returns {String} the QTI coords
*/
qtiCoords: function qtiCoords(element, paper, width) {
const mapper = raph2qtiCoordsMapper[element.type];
let result = '';
const factor = paper && width ? width / paper.w : 1;
if (_.isFunction(mapper)) {
result = _.map(mapper.call(raph2qtiCoordsMapper, element.attr()), function (coord) {
return Math.round(coord * factor);
}).join(',');
}
return result;
},
/**
* Create a circle that animate and disapear from a shape.
*
* @param {Raphael.Paper} paper - the paper
* @param {Raphael.Element} element - used to get the bbox from
*/
createTouchCircle: function (paper, bbox) {
const radius = bbox.width > bbox.height ? bbox.width : bbox.height;
const tCircle = paper.circle(bbox.x + bbox.width / 2, bbox.y + bbox.height / 2, radius);
tCircle.attr(gstyle['touch-circle']);
_.defer(function () {
tCircle.animate({ r: radius + 5, opacity: 0.7 }, 300, function () {
tCircle.remove();
});
});
},
/**
* Create a text, that scales.
*
* @param {Raphael.Paper} paper - the paper
* @param {Object} options - the text options
* @param {Number} options.left - x coord
* @param {Number} options.top - y coord
* @param {String} [options.content] - the text content
* @param {String} [options.id] - the element identifier
* @param {String} [options.style = 'small-text'] - the style name according to the graphic-style.json keys
* @param {String} [options.title] - the text tooltip content
* @param {String} [options.disableEvents] - ignore events for the node
* @param {Boolean} [options.hide = false] - if the text starts hidden
* @returns {Raphael.Element} the created text
*/
createText: function (paper, options) {
const top = options.top || 0;
const left = options.left || 0;
const content = options.content || '';
const style = options.style || 'small-text';
const title = options.title || '';
const disableEvents = options.disableEvents || false;
let factor = 1;
if (paper.width && paper.w) {
factor = paper.width / paper.w;
}
const text = paper.text(left, top, content).toFront();
if (options.id) {
text.id = options.id;
}
if (options.hide) {
text.hide();
}
text.attr(gstyle[style]);
if (disableEvents) {
text.node.setAttribute('pointer-events', 'none');
}
if (typeof factor !== 'undefined' && factor !== 1) {
const fontSize = parseInt(text.attr('font-size'), 10);
const scaledFontSize = Math.floor(fontSize / factor) + 1;
text.attr('font-size', scaledFontSize);
}
if (title) {
this.updateTitle(text, title);
}
return text;
},
/**
* Create a text in the middle of the related shape.
*
* @param {Raphael.Paper} paper - the paper
* @param {Raphael.Element} shape - the shape to add the text to
* @param {Object} options - the text options
* @param {String} [options.content] - the text content
* @param {String} [options.id] - the element identifier
* @param {String} [options.style = 'small-text'] - the style name according to the graphic-style.json keys
* @param {String} [options.title] - the text tooltip content
* @param {Boolean} [options.hide = false] - if the text starts hidden
* @param {Boolean} [options.shapeClick = false] - clicking the text delegates to the shape
* @returns {Raphael.Element} the created text
*/
createShapeText: function (paper, shape, options) {
const bbox = shape.getBBox();
const text = this.createText(
paper,
_.merge(
{
left: bbox.x + bbox.width / 2,
top: bbox.y + bbox.height / 2
},
options
)
);
if (options.shapeClick) {
text.click(() => {
this.trigger(shape, 'click');
});
}
return text;
},
/**
* Create an image with a padding and a border, using a set.
*
* @param {Raphael.Paper} paper - the paper
* @param {Object} options - image options
* @param {Number} options.left - x coord
* @param {Number} options.top - y coord
* @param {Number} options.width - image width
* @param {Number} options.height - image height
* @param {Number} options.url - image ulr
* @param {Number} [options.padding = 6] - a multiple of 2 is welcomed
* @param {Boolean} [options.border = false] - add a border around the image
* @param {Boolean} [options.shadow = false] - add a shadow back the image
* @returns {Raphael.Element} the created set, augmented of a move(x,y) method
*/
createBorderedImage: function (paper, options) {
const padding = options.padding >= 0 ? options.padding : 6;
const halfPad = padding / 2;
const rx = options.left,
ry = options.top,
rw = options.width + padding,
rh = options.height + padding;
const ix = options.left + halfPad,
iy = options.top + halfPad,
iw = options.width,
ih = options.height;
const set = paper.set();
//create a rectangle with a padding and a border.
const rect = paper
.rect(rx, ry, rw, rh)
.attr(options.border ? gstyle['imageset-rect-stroke'] : gstyle['imageset-rect-no-stroke']);
//and an image centered into the rectangle.
const image = paper.image(options.url, ix, iy, iw, ih).attr(gstyle['imageset-img']);
if (options.shadow) {
set.push(
rect.glow({
width: 2,
offsetx: 1,
offsety: 1
})
);
}
set.push(rect, image);
/**
* Add a move method to set that keep the given coords during an animation
* @private
* @param {Number} x - destination
* @param {Number} y - destination
* @param {Number} [duration = 400] - the animation duration
* @returns {Raphael.Element} the set for chaining
*/
set.move = function move(x, y, duration) {
const animation = raphael.animation({ x: x, y: y }, duration || 400);
const elt = rect.animate(animation);
image.animateWith(elt, animation, { x: x + halfPad, y: y + halfPad }, duration || 400);
return set;
};
return set;
},
/**
* Update the visual state of an Element
* @param {Raphael.Element} element - the element to change the state
* @param {String} state - the name of the state (from states) to switch to
* @param {String} [title] - a title linked to this step
*/
updateElementState: function (element, state, title) {
if (element && element.animate) {
element.animate(gstyle[state], 200, 'linear', function () {
element.attr(gstyle[state]); //for attr that don't animate
//preven issue in firefox 37
$(element.node).removeAttr('stroke-dasharray');
});
if (title) {
this.updateTitle(element, title);
}
}
},
/**
* Update the title of an element (the attr method of Raphael adds only new node instead of updating exisitings).
* @param {Raphael.Element} element - the element to update the title
* @param {String} [title] - the new title
*/
updateTitle: function (element, title) {
if (element && element.node) {
//removes all remaining titles nodes
_.forEach(element.node.children, function (child) {
if (child.nodeName.toLowerCase() === 'title') {
element.node.removeChild(child);
}
});
//then set the new title
element.attr('title', title);
}
},
/**
* Highlight an element with the error style
* @param {Raphael.Element} element - the element to hightlight
* @param {String} [restorState = 'basic'] - the state to restore the elt into after flash
*/
highlightError: function (element, restoredState) {
if (element) {
element.flashing = true;
this.updateElementState(element, 'error');
_.delay(() => {
this.updateElementState(element, restoredState || 'basic');
element.flashing = false;
}, 800);
}
},
/**
* Trigger an event already bound to a raphael element
* @param {Raphael.Element} element
* @param {String} event - the event name
*
*/
trigger: function (element, event) {
const evt = element.events.filter(el => el.name === event);
if (evt.length && evt[0] && typeof evt[0].f === 'function') {
evt[0].f.apply(element, Array.prototype.slice.call(arguments, 2));
}
},
/**
* Get an x/y point from a MouseEvent
* @param {MouseEvent} event - the source event
* @param {Raphael.Paper} paper - the interaction paper
* @param {jQueryElement} $container - the paper container
* @param {Boolean} isResponsive - if the paper is scaling
* @returns {Object} x,y point
*/
getPoint: function getPoint(event, paper, $container) {
const point = this.clickPoint($container, event);
const rect = $container.get(0).getBoundingClientRect();
const factor = paper.w / rect.width;
point.x = Math.round(point.x * factor);
point.y = Math.round(point.y * factor);
return point;
},
/**
* Get paper position relative to the container
* @param {jQueryElement} $container - the paper container
* @param {Raphael.Paper} paper - the interaction paper
* @returns {Object} position with top and left
*/
position: function ($container, paper) {
const pw = parseInt(paper.w || paper.width, 10);
const cw = parseInt($container.width(), 10);
const ph = parseInt(paper.w || paper.width, 10);
const ch = parseInt($container.height(), 10);
return {
left: (cw - pw) / 2,
top: (ch - ph) / 2
};
},
/**
* Get a point from a click event
* @param {jQueryElement} $container - the element that contains the paper
* @param {MouseEvent} event - the event triggered by the click
* @returns {Object} the x,y point
*/
clickPoint: function ($container, event) {
let x, y;
const offset = $container.offset();
if (event.pageX || event.pageY) {
x = event.pageX - offset.left;
y = event.pageY - offset.top;
} else if (event.clientX || event.clientY) {
x = event.clientX + document.body.scrollLeft + document.documentElement.scrollLeft - offset.left;
y = event.clientY + document.body.scrollTop + document.documentElement.scrollTop - offset.top;
}
return { x, y };
}
};
export default GraphicHelper;