/**
* File : activities/text/TextActivityDocument.js
* Created : 14/04/2015
* By : Francesc Busquets <francesc@gmail.com>
*
* JClic.js
* An HTML5 player of JClic activities
* https://projectestac.github.io/jclic.js
*
* @source https://github.com/projectestac/jclic.js
*
* @license EUPL-1.2
* @licstart
* (c) 2000-2020 Educational Telematic Network of Catalonia (XTEC)
*
* Licensed under the EUPL, Version 1.1 or -as soon they will be approved by
* the European Commission- subsequent versions of the EUPL (the "Licence");
* You may not use this work except in compliance with the Licence.
*
* You may obtain a copy of the Licence at:
* https://joinup.ec.europa.eu/software/page/eupl
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the Licence is distributed on an "AS IS" basis, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* Licence for the specific language governing permissions and limitations
* under the Licence.
* @licend
* @module
*/
import $ from 'jquery';
import { log, attrForEach, checkColor, getBoolean, getAttr, setAttr, getVal, getNumber, settings } from '../../Utils.js';
import ActiveBoxContent from '../../boxes/ActiveBoxContent.js';
import MediaContent from '../../media/MediaContent.js';
/**
* This is the HTML DOM element used in text activities like {@link module:activities/text/FillInBlanks.FillInBlanks FillInBlanks},
* {@link module:activities/text/IdentifyText.IdentifyText IdentifyText}, {@link module:activities/text/OrderText.OrderText OrderText} and {@link module:activities/text/Complete.Complete Complete}. It contains the main document of
* the activity, usually with some elements marked as "targets". In {@link module:activities/text/FillInBlanks.FillInBlanks FillInBlanks}, this
* targets are encapsulated in {@link module:activities/text/TextActivityDocument.TextTarget TextTarget} objects.
*/
export class TextActivityDocument {
/**
* TextActivityDocument constructor
*/
constructor() {
// Make a deep clone of the default style
this.style = { 'default': { ...TextActivityDocument.DEFAULT_DOC_STYLE } };
this.p = [];
}
/**
* Loads the document settings from a specific JQuery XML element
* @param {external:jQuery} $xml - The XML element to parse
* @param {module:bags/MediaBag.MediaBag} mediaBag - The media bag used to load images and media content
*/
setProperties($xml, mediaBag) {
// Read named styles
// Sort styles according to its "base" dependencies
const styles = $xml.children('style').toArray().sort((a, b) => {
var aName = a.getAttribute('name'), aBase = a.getAttribute('base') || null;
var bName = b.getAttribute('name'), bBase = b.getAttribute('base') || null;
// Put 'default' always first, then each style below their base (if any)
return aName === 'default' ? -1 : bName === 'default' ? 1
: aBase === bName ? 1 : bBase === aName ? -1
: !aBase ? -1 : !bBase ? 1 : 0;
});
// Process the ordered list of styles
styles.forEach(style => {
const attr = this.readDocAttributes($(style));
// Grant always that basic attributes are defined
this.style[attr.name] = attr.name === 'default' ? $.extend(true, this.style.default, attr) : attr;
});
// Read paragraphs
$xml.find('section > p').each((_n, par) => {
const p = { elements: [] };
// Read paragraph attributes
attrForEach(par.attributes, (name, value) => {
switch (name) {
case 'style':
p[name] = value;
break;
case 'bidiLevel':
case 'Alignment':
p[name] = Number(value);
break;
}
});
// Read paragraph objects
$(par).children().each((_n, child) => {
let obj;
const $child = $(child);
switch (child.nodeName) {
case 'cell':
obj = new ActiveBoxContent().setProperties($child, mediaBag);
break;
case 'text':
obj = { text: child.textContent.replace(/\t/g, '	') };
const attr = this.readDocAttributes($child);
if (!$.isEmptyObject(attr)) {
obj.attr = attr;
}
break;
case 'target':
obj = new TextTarget(this, child.textContent.replace(/\t/g, '	'));
obj.setProperties($child, mediaBag);
this.numTargets++;
break;
default:
log('error', `Unknown object in activity document: "${child.nodeName}"`);
}
if (obj) {
obj.objectType = child.nodeName;
p.elements.push(obj);
}
});
this.p.push(p);
});
return this;
}
/**
* Reads sets of text attributes, sometimes in form of named styles
* @param {external:jQuery} $xml - The XML element to parse
* @returns {object}
*/
readDocAttributes($xml) {
let
attr = {},
css = {};
attrForEach($xml.get(0).attributes, (name, val) => {
switch (name) {
case 'background':
val = checkColor(val, 'white');
attr[name] = val;
css['background-color'] = val;
break;
case 'foreground':
val = checkColor(val, 'black');
attr[name] = val;
css['color'] = val;
break;
case 'family':
css['font-family'] = val;
/* falls through */
case 'name':
case 'style':
// Attributes specific to named styles:
attr[name] = val;
break;
case 'base':
attr[name] = val;
// If base style exists, merge it with current settings
if (this.style[val]) {
//attr = Object.apply({}, this.style[val], attr)
attr = $.extend(true, {}, this.style[val], attr);
if (this.style[val].css)
//css = Object.apply({}, this.style[val].css, css)
css = $.extend({}, this.style[val].css, css);
}
break;
case 'bold':
val = getBoolean(val);
attr[name] = val;
css['font-weight'] = val ? 'bold' : 'normal';
break;
case 'italic':
val = getBoolean(val);
attr[name] = val;
css['font-style'] = val ? 'italic' : 'normal';
break;
case 'target':
attr[name] = getBoolean(val);
break;
case 'size':
attr[name] = Number(val);
css['font-size'] = `${val}px`;
break;
case 'tabWidth':
// `tab-size` CSS attribute is only set when the document has a specific `tabWidth`
// setting. It must be accompanied of `white-space:pre` to successfully work.
this.tabSpc = val;
css['tab-size'] = this.tabSpc;
css['white-space'] = 'pre-wrap';
break;
default:
log('warn', `Unknown text attribute: "${name}" = "${val}"`);
attr[name] = val;
break;
}
});
if (!$.isEmptyObject(css))
attr['css'] = css;
return attr;
}
/**
* Gets a object with the basic attributes needed to rebuild this instance excluding functions,
* parent references, constants and also attributes retaining the default value.
* The resulting object is commonly usued to serialize elements in JSON format.
* @returns {object} - The resulting object, with minimal attrributes
*/
getAttributes() {
// TODO: simplify the serialization of styles (now too verbose!)
return getAttr(this, ['style', 'tabSpc', 'targetType', 'p']);
}
/**
* Reads the properties of this TextActivityDocument from a data object
* @param {object} data - The data object to be parsed, or just the text content
* @returns {module:activities/text/TextActivityDocument.TextActivityDocument}
*/
setAttributes(data, mediaBag) {
setAttr(this, data, ['style', 'tabSpc', 'targetType', 'p']);
// Build paragraphs:
this.p.forEach(p => {
if (p.elements)
p.elements = p.elements.map(el => {
if (el.objectType === 'cell')
return (new ActiveBoxContent()).setAttributes(el, mediaBag);
else if (el.objectType === 'target')
return (new TextTarget(this)).setAttributes(el, mediaBag);
else
return el;
});
else
p.elements = [];
});
return this;
}
/**
* Gets the full text of this document in raw format
* @returns {string} - The text of the document.
*/
getRawText() {
const $html = $('<div/>');
// Process paragraphs
this.p.forEach(p => {
// Creates a new DOM paragraph
const $p = $('<p/>');
let empty = true;
// Process the paragraph elements
p.elements.forEach(element => {
switch (element.objectType) {
case 'text':
case 'target':
$p.append(element.text);
break;
case 'cell':
// cells are not considered raw text of the document
break;
default:
break;
}
empty = false;
});
if (empty) {
// Don't leave paragraphs empty
$p.html(' ');
}
// Adds the paragraph to the DOM element
$html.append($p);
});
return $html.text().trim();
}
/**
* Gets a `style` object filled with default attributes plus attributes present in the
* requested style name.
* @param {string} name - The requested style name
* @returns {object} - The result of combining `default` with the requested style
*/
getFullStyle(name) {
const st = $.extend(true, {}, this.style.default);
return $.extend(true, st, this.style[name] ? this.style[name] : {});
//return Object.assign({}, this.style.default, this.style[name] ? this.style[name] : {})
}
}
/**
* Default style
*/
TextActivityDocument.DEFAULT_DOC_STYLE = {
background: '0xFFFFFF',
foreground: '0x000000',
family: 'Arial',
bold: false,
italic: false,
size: 17,
css: {
'background-color': '#FFFFFF',
'color': '#000000',
'font-family': 'Arial',
'font-weight': 'normal',
'font-style': 'normal',
'font-size': '17px',
},
};
Object.assign(TextActivityDocument.prototype, {
/**
* Number of blank spaces between tabulators.
* @name module:activities/text/TextActivityDocument.TextActivityDocument#tabSpc
* @type {number} */
tabSpc: 12,
/**
* Index of the last {@link module:boxes/ActiveBox.ActiveBox ActiveBox} activated.
* @name module:activities/text/TextActivityDocument.TextActivityDocument#lastBoxId
* @type {number} */
lastBoxId: 0,
/**
* A bag of {@link module:activities/text/TextActivityDocument.TargetMarker TargetMarker} objects
* @name module:activities/text/TextActivityDocument.TextActivityDocument#tmb
* @type {object} */
tmb: null,
/**
* Number of targets
* @name module:activities/text/TextActivityDocument.TextActivityDocument#numTargets
* @type {number} */
numTargets: 0,
/**
* Type of targets used in this activity. Possible values are: `TT_FREE`, `TT_CHAR`, `TT_WORD`
* and `TT_PARAGRAPH`.
* @name module:activities/text/TextActivityDocument.TextActivityDocument#targetType
* @type {string} */
targetType: 'TT_FREE',
/**
* Collection of named styles of the document
* @name module:activities/text/TextActivityDocument.TextActivityDocument#style
* @type {object} */
style: null,
/**
* The main document, represented as a collection of DOM objects
* @name module:activities/text/TextActivityDocument.TextActivityDocument#p
* @type {object} */
p: null,
});
/**
* This class contains the properties and methods of the document elements that are the real
* targets of user actions in text activities.
*/
export class TextTarget {
/**
* TextTarget constructor
* @param {module:activities/text/TextActivityDocument.TextActivityDocument} doc - The document to which this target belongs.
* @param {string} text - Main text of this target.
*/
constructor(doc, text = '') {
this.doc = doc;
this.text = text;
this.numIniChars = text.length;
this.answers = [text];
this.maxLenResp = this.numIniChars;
}
/**
* Resets the TextTarget status
* @param {string} [status] - The `targetStatus` to be established. Default is `NOT_EDITED`
*/
reset(status) {
this.targetStatus = status ? status : 'NOT_EDITED';
this.flagModified = false;
}
/**
* Loads the text target settings from a specific JQuery XML element
* @param {external:jQuery} $xml - The XML element to parse
* @param {module:bags/MediaBag.MediaBag} mediaBag - The media bag used to load images and media content
*/
setProperties($xml, mediaBag) {
let firstAnswer = true;
// Read specific nodes
$xml.children().each((_n, child) => {
const $node = $(child);
switch (child.nodeName) {
case 'answer':
if (firstAnswer) {
firstAnswer = false;
this.answers = [];
}
if (this.answers === null)
this.answers = [];
this.answers.push(child.textContent);
break;
case 'optionList':
$node.children('option').each((_n, opChild) => {
this.isList = true;
if (this.options === null)
this.options = [];
this.options.push(opChild.textContent);
});
break;
case 'response':
this.iniChar = getVal($node.attr('fill'), this.iniChar).charAt(0);
// Use underscores instead of whitespace chars
if (settings.WHITESPACES.indexOf(this.iniChar) >= 0)
this.iniChar = '_';
this.numIniChars = getNumber($node.attr('length'), this.numIniChars);
this.maxLenResp = getNumber($node.attr('maxLength'), this.maxLenResp);
this.iniText = getVal($node.attr('show'), this.iniText);
break;
case 'info':
this.infoMode = getVal($node.attr('mode'), 'always');
this.popupDelay = getNumber($node.attr('delay'), this.popupDelay);
this.popupMaxTime = getNumber($node.attr('maxTime'), this.popupMaxTime);
$node.children('media').each((_n, media) => {
this.onlyPlay = true;
this.popupContent = new ActiveBoxContent();
this.popupContent.mediaContent = new MediaContent().setProperties($(media));
});
if (!this.popupContent) {
$node.children('cell').each((_n, cell) => {
this.popupContent = new ActiveBoxContent().setProperties($(cell), mediaBag);
});
}
break;
case 'text':
this.text = child.textContent.replace(/\t/g, '	');
const attr = this.doc.readDocAttributes($(child));
if (!$.isEmptyObject(attr))
this.attr = attr;
break;
default:
break;
}
});
}
/**
* Gets a object with the basic attributes needed to rebuild this instance excluding functions,
* parent references, constants and also attributes retaining the default value.
* The resulting object is commonly usued to serialize elements in JSON format.
* @returns {object} - The resulting object, with minimal attrributes
*/
getAttributes() {
return getAttr(this, [
'objectType', 'text', 'attr', 'isList',
'answers', 'options', 'iniChar', 'numIniChars', 'maxLenResp', 'iniText',
'infoMode', 'popupDelay', 'popupKey', 'popupMaxTime', 'onlyPlay',
'popupContent',
]);
}
/**
* Reads the properties of this TextTarget from a data object
* @param {object} data - The data object to be parsed, or just the text content
* @returns {module:activities/text/TextActivityDocument.TextTarget}
*/
setAttributes(data, mediaBag) {
return setAttr(this, data, [
'objectType', 'text', 'attr', 'isList',
'answers', 'options', 'iniChar', 'numIniChars', 'maxLenResp', 'iniText',
'infoMode', 'popupDelay', 'popupKey', 'popupMaxTime', 'onlyPlay',
{ key: 'popupContent', fn: ActiveBoxContent, params: [mediaBag] },
]);
}
/**
* Gets a string with all valid answers of this TextTarget. Useful for reporting users' activity.
* @returns {string}
*/
getAnswers() {
return this.answers ? this.answers.join('|') : '';
}
/**
* Sets specific colors to the target jQuery element, based on its `targetStatus` value. Red
* color usually means error.
*/
checkColors() {
const $element = this.$comboList || this.$span;
if ($element) {
const style = this.doc.style[
this.targetStatus === 'WITH_ERROR' ? 'targetError' :
this.targetStatus === 'HIDDEN' ? 'default' : 'target'];
if (style && style.css) {
$element.css(style.css);
}
}
}
/**
* Fills the `currentText` member with the text currently hosted in $span or selected in $comboList
* @returns {string} - The current text of this target
*/
readCurrentText() {
if (this.$span)
this.currentText = this.$span.text();
else if (this.$comboList)
this.currentText = this.$comboList.val();
return this.currentText;
}
}
Object.assign(TextTarget.prototype, {
/**
* The {@link module:activities/text/TextActivityDocument.TextActivityDocument TextActivityDocument} to which this target belongs
* @name module:activities/text/TextActivityDocument.TextTarget#doc
* @type {module:activities/text/TextActivityDocument.TextActivityDocument} */
doc: null,
/**
* The current text displayed by this TextTarget
* @name module:activities/text/TextActivityDocument.TextTarget#text
* @type {string} */
text: null,
/**
* A set of optional attributes for `text`
* @name module:activities/text/TextActivityDocument.TextTarget#attr
* @type {object} */
attr: null,
/**
* `true` when the target is a drop-down list
* @name module:activities/text/TextActivityDocument.TextTarget#isList
* @type {boolean} */
isList: false,
/**
* Number of characters initially displayed on the text field
* @name module:activities/text/TextActivityDocument.TextTarget#numIniChars
* @type {number} */
numIniChars: 1,
/**
* Character used to fill-in the text field
* @name module:activities/text/TextActivityDocument.TextTarget#iniChar
* @type {string} */
iniChar: '_',
/**
* Maximum length of the answer
* @name module:activities/text/TextActivityDocument.TextTarget#maxLenResp
* @type {number} */
maxLenResp: 0,
/**
* Array of valid answers
* @name module:activities/text/TextActivityDocument.TextTarget#answers
* @type {string[]} */
answers: null,
/**
* Set of specific options
* @name module:activities/text/TextActivityDocument.TextTarget#options
* @type {object} */
options: null,
/**
* Text displayed by the target when the activity begins
* @name module:activities/text/TextActivityDocument.TextTarget#iniText
* @type {string} */
iniText: null,
/**
* Type of additional information offered to the user. Possible values are: `no_info`, `always`,
* `onError` and `onDemand`.
* @name module:activities/text/TextActivityDocument.TextTarget#infoMode
* @type {string} */
infoMode: 'no_info',
/**
* Key that triggers the associated popup when `infoMode` is `onDemand`
* @name module:activities/text/TextActivityDocument.TextTarget#popupKey
* @type {string} */
popupKey: 'F1',
/**
* An optional {@link module:boxes/ActiveBoxContent.ActiveBoxContent ActiveBoxContent} with information about this TextTarget
* @name module:activities/text/TextActivityDocument.TextTarget#popupContent
* @type {module:boxes/ActiveBoxContent.ActiveBoxContent} */
popupContent: null,
/**
* Time (seconds) to wait before showing the additional information
* @name module:activities/text/TextActivityDocument.TextTarget#popupDelay
* @type {number} */
popupDelay: 0,
/**
* Maximum amount of time (seconds) that the additional information will be shown
* @name module:activities/text/TextActivityDocument.TextTarget#popupMaxTime
* @type {number} */
popupMaxTime: 0,
/**
* When this flag is `true` and `popupContent` contains audio, no visual feedback will be
* provided (meaning that audio will be just played)
* @name module:activities/text/TextActivityDocument.TextTarget#onlyPlay
* @type {boolean} */
onlyPlay: false,
//
// TRANSIENT PROPERTIES
//
/**
* The drop-down list associated to this target
* @name module:activities/text/TextActivityDocument.TextTarget#$comboList
* @type {external:jQuery} */
$comboList: null,
/**
* The span element associated to this target
* @name module:activities/text/TextActivityDocument.TextTarget#$span
* @type {external:jQuery} */
$span: null,
/**
* The paragraph element where $span is currently located
* @name module:activities/text/TextActivityDocument.TextTarget#$p
* @type {external:jQuery} */
$p: null,
/**
* The span element containing the popup
* @name module:activities/text/TextActivityDocument.TextTarget#$popup
* @type {external:jQuery} */
$popup: null,
/**
* Current text in the $span element
* @name module:activities/text/TextActivityDocument.TextTarget#currentText
* @type {string} */
currentText: '',
/**
* Ordinal number of this target in the collection of targets
* @name module:activities/text/TextActivityDocument.TextTarget#num
* @type {number} */
num: 0,
/**
* Current ordinal position of this target in the document
* (used in {@link module:activities/text/OrderText.OrderText OrderText} activities)
* @name module:activities/text/TextActivityDocument.TextTarget#pos
* @type {number} */
pos: 0,
/**
* Current status of the target. Valid values are: `NOT_EDITED`, `EDITED`, `SOLVED`, `WITH_ERROR` and `HIDDEN`
* @name module:activities/text/TextActivityDocument.TextTarget#targetStatus
* @type {string} */
targetStatus: 'NOT_EDITED',
/**
* Flag to control if the initial content of this TextTarget has been modified
* @name module:activities/text/TextActivityDocument.TextTarget#flagModified
* @type {boolean} */
flagModified: false,
/**
* Pointer to the activity panel containing this TextTarget
* @name module:activities/text/TextActivityDocument.TextTarget#parentPane
* @type {module:activities/text/TextActivityBase.TextActivityBasePanel} */
parentPane: null,
});
TextActivityDocument.TextTarget = TextTarget;
export default TextActivityDocument;