From 9ea03b6cd70932fcc5ee26e655a211cd19bb58bf Mon Sep 17 00:00:00 2001 From: Christian Lawson-Perfect Date: Thu, 16 Nov 2023 11:14:26 +0000 Subject: [PATCH] move display utility functions to display-util.js Trying to reduce the amount of duplication of code between the default theme and the worksheet theme. This commit moves a few utility functions to display-util.js, which display-base imports. They're under a new namespace Numbas.display_util, but references under Numbas.display are kept for backwards compatibility with existing custom themes. fixes #1060 --- themes/default/files/scripts/display-base.js | 354 +----------------- themes/default/files/scripts/display-util.js | 353 +++++++++++++++++ themes/default/files/scripts/exam-display.js | 8 +- .../files/scripts/knockout-handlers.js | 4 +- themes/default/files/scripts/part-display.js | 4 +- .../default/files/scripts/question-display.js | 6 +- .../worksheet/files/scripts/display-base.js | 300 +-------------- 7 files changed, 394 insertions(+), 635 deletions(-) create mode 100644 themes/default/files/scripts/display-util.js diff --git a/themes/default/files/scripts/display-base.js b/themes/default/files/scripts/display-base.js index 0d664fcc2..5eaf664bc 100644 --- a/themes/default/files/scripts/display-base.js +++ b/themes/default/files/scripts/display-base.js @@ -1,32 +1,9 @@ -Numbas.queueScript('display-base',['controls','math','xml','util','timing','jme','jme-display'],function() { +Numbas.queueScript('display-base',['display-util', 'controls','math','xml','util','timing','jme','jme-display'],function() { var util = Numbas.util; var jme = Numbas.jme; +var display_util = Numbas.display_util; /** @namespace Numbas.display */ var display = Numbas.display = /** @lends Numbas.display */ { - /** Localise strings in page HTML - for tags with an attribute `data-localise`, run that attribute through R.js to localise it, and replace the tag's HTML with the result. - */ - localisePage: function() { - $('[data-localise]').each(function() { - var localString = R($(this).data('localise')); - $(this).html(localString); - }); - }, - /** Get the attribute with the given name or, if it doesn't exist, look for localise-. - * If that exists, localise its value and set the desired attribute, then return it. - * - * @param {Element} elem - * @param {string} name - * @returns {string} - */ - getLocalisedAttribute: function(elem, name) { - var attr_localise; - var attr = elem.getAttribute(name); - if(!attr && (attr_localise = elem.getAttribute('localise-'+name))) { - attr = R(attr_localise); - elem.setAttribute(name,attr); - } - return attr; - }, /** Update the progress bar when loading. */ showLoadProgress: function() @@ -124,8 +101,8 @@ var display = Numbas.display = /** @lends Numbas.display */ { Knockout.computed(function() { var backgroundColour = vm.style.backgroundColour(); - var rgb = parseRGB(backgroundColour); - var hsl = RGBToHSL(rgb[0],rgb[1],rgb[2]); + var rgb = display_util.parseRGB(backgroundColour); + var hsl = display_util.RGBToHSL(rgb[0],rgb[1],rgb[2]); var oppositeBackgroundColour = hsl[2]<0.5 ? '255,255,255' : '0,0,0'; var css_vars = { '--background-colour': vm.style.backgroundColour(), @@ -379,318 +356,19 @@ var display = Numbas.display = /** @lends Numbas.display */ { $('#die').show(); $('#die .error .message').html(message); $('#die .error .stack').html(stack); - } -}; + }, -/** Parse a colour in hexadecimal RGB format into separate red, green and blue components. - * - * @param {string} hex - The hex string representing the colour, in the form `#000000`. - * @returns {Array.} - An array of the form `[r,g,b]`. - */ -function parseRGB(hex) { - var r = parseInt(hex.slice(1,3)); - var g = parseInt(hex.slice(3,5)); - var b = parseInt(hex.slice(5,7)); - return [r,g,b]; -} - -/** Convert a colour given in red, green, blue components to hue, saturation, lightness. - * From https://css-tricks.com/converting-color-spaces-in-javascript/. - * - * @param {number} r - The red component. - * @param {number} g - The green component. - * @param {number} b - The blue component. - * @returns {Array.} - The colour in HSL format, an array of the form `[h,s,l]`. - * */ -function RGBToHSL(r,g,b) { - r /= 255; - g /= 255; - b /= 255; - - var cmin = Math.min(r,g,b); - var cmax = Math.max(r,g,b); - var delta = cmax - cmin; - - var h,s,l; - - if (delta == 0) { - h = 0; - } else if (cmax == r) { - h = ((g - b) / delta) % 6; - } else if (cmax == g) { - h = (b - r) / delta + 2; - } else { - h = (r - g) / delta + 4; - } - - h = (h*60) % 360; - - if (h < 0) { - h += 360; - } - - l = (cmax + cmin) / 2; - - s = delta == 0 ? 0 : delta / (1 - Math.abs(2 * l - 1)); - - return [h,s,l]; -} - -/** Convert a colour in hue, saturation, lightness format to red, green, blue. - * From https://css-tricks.com/converting-color-spaces-in-javascript/. - * - * @param {number} h - The hue component. - * @param {number} s - The saturation component. - * @param {number} l - The lightness component. - * @returns {Array.} - An array of the form `[r,g,b]`. - */ -function HSLToRGB(h,s,l) { - var c = (1 - Math.abs(2 * l - 1)) * s; - var x = c * (1 - Math.abs((h / 60) % 2 - 1)); - var m = l - c/2; - - var r,g,b; - - if (0 <= h && h < 60) { - r = c; g = x; b = 0; - } else if (60 <= h && h < 120) { - r = x; g = c; b = 0; - } else if (120 <= h && h < 180) { - r = 0; g = c; b = x; - } else if (180 <= h && h < 240) { - r = 0; g = x; b = c; - } else if (240 <= h && h < 300) { - r = x; g = 0; b = c; - } else if (300 <= h && h < 360) { - r = c; g = 0; b = x; - } - r = (r + m) * 255; - g = (g + m) * 255; - b = (b + m) * 255; - - return [r,g,b]; -} - -var measurer; -var measureText_cache = {}; -display.measureText = function(element) { - var styles = window.getComputedStyle(element); - - if(!measurer) { - measurer = document.createElement('div'); - measurer.style['position'] = 'absolute'; - measurer.style['left'] = '-10000'; - measurer.style['top'] = '-10000'; - measurer.style['visibility'] = 'hidden'; - } - - var keys = ['font-size','font-style', 'font-weight', 'font-family', 'line-height', 'text-transform', 'letter-spacing']; - var id = element.value+';'+keys.map(function(key) { return styles[key]; }).join(';'); - if(measureText_cache[id]) { - return measureText_cache[id]; - } - keys.forEach(function(key) { - measurer.style[key] = styles[key]; - }); - measurer.textContent = element.value; - document.body.appendChild(measurer); - var box = measurer.getBoundingClientRect(); - measureText_cache[id] = box; - document.body.removeChild(measurer); - return box; -} - -/** An object which can produce feedback: {@link Numbas.Question} or {@link Numbas.parts.Part}. - * - * @typedef {object} Numbas.display.feedbackable - * @property {observable.} answered - Has the object been answered? - * @property {observable.} isDirty - Has the student's answer changed? - * @property {observable.} score - Number of marks awarded - * @property {observable.} marks - Number of marks available - * @property {observable.} credit - Proportion of available marks awarded - * @property {observable.} doesMarking - Does the object do any marking? - * @property {observable.} revealed - Have the correct answers been revealed? - * @property {boolean} plainScore - Show the score without the "Score: " prefix? - */ -/** Settings for {@link Numbas.display.showScoreFeedback} - * - * @typedef {object} Numbas.display.showScoreFeedback_settings - * @property {boolean} showTotalMark - Show the total marks available? - * @property {boolean} showActualMark - Show the student's current score? - * @property {boolean} showAnswerState - Show the correct/incorrect state after marking? - * @property {boolean} reviewShowScore - Show the score once answers have been revealed? - */ -/** Feedback states for a question or part: "wrong", "correct", "partial" or "none". - * - * @typedef {string} Numbas.display.feedback_state - */ -/** A model representing feedback on an item which is marked - a question or a part. - * - * @typedef {object} Numbas.display.scoreFeedback - * @property {observable.} update - Call `update(true)` when the score changes. Used to trigger animations. - * @property {observable.} state - The current state of the item, to be shown to the student. - * @property {observable.} answered - Has the item been answered? False if the student has changed their answer since submitting. - * @property {observable.} answeredString - Translated text describing how much of the item has been answered: 'unanswered', 'partially answered' or 'answered' - * @property {observable.} message - Text summarising the state of the item. - * @property {observable.} iconClass - CSS class for the feedback icon. - * @property {observable.} iconAttr - A dictionary of attributes for the feedback icon. - */ -/** Update a score feedback box. - * - * @param {Numbas.display.feedbackable} obj - Object to show feedback about. - * @param {Numbas.display.showScoreFeedback_settings} settings - * @memberof Numbas.display - * @returns {Numbas.display.scoreFeedback} - */ -var showScoreFeedback = display.showScoreFeedback = function(obj,settings) -{ - var niceNumber = Numbas.math.niceNumber; - var scoreDisplay = ''; - var newScore = Knockout.observable(false); - var answered = Knockout.computed(function() { - return obj.answered(); - }); - var attempted = Knockout.computed(function() { - return obj.visited!==undefined && obj.visited(); - }); - var showFeedbackIcon = settings.showFeedbackIcon === undefined ? settings.showAnswerState : settings.showFeedbackIcon; - var anyAnswered = Knockout.computed(function() { - if(obj.anyAnswered===undefined) { - return answered(); - } else { - return obj.anyAnswered(); - } - }); - var partiallyAnswered = Knockout.computed(function() { - return anyAnswered() && !answered(); - },this); - var revealed = Knockout.computed(function() { - return (obj.revealed() && settings.reviewShowScore) || Numbas.is_instructor; - }); - var state = Knockout.computed(function() { - var score = obj.score(); - var marks = obj.marks(); - var credit = obj.credit(); - if( obj.doesMarking() && showFeedbackIcon && (revealed() || (settings.showAnswerState && anyAnswered())) ) { - if(credit<=0) { - return 'wrong'; - } else if(Numbas.math.precround(credit,10)>=1) { - return 'correct'; - } else { - return 'partial'; - } - } - else { - return 'none'; - } - }); - var messageIngredients = ko.computed(function() { - var score = obj.score(); - var marks = obj.marks(); - var scoreobj = { - marks: marks, - score: score, - marksString: niceNumber(marks)+' '+R('mark',{count:marks}), - scoreString: niceNumber(score)+' '+R('mark',{count:score}), - }; - var messageKey; - if(marks==0) { - messageKey = 'question.score feedback.not marked'; - } else if(!revealed()) { - if(settings.showActualMark) { - if(settings.showTotalMark) { - messageKey = 'question.score feedback.score total actual'; - } else { - messageKey = 'question.score feedback.score actual'; - } - } else if(settings.showTotalMark) { - messageKey = 'question.score feedback.score total'; - } else { - var key = answered () ? 'answered' : anyAnswered() ? 'partially answered' : 'unanswered'; - messageKey = 'question.score feedback.'+key; - } - } else { - messageKey = 'question.score feedback.score total actual'; - } - return {key: messageKey, scoreobj: scoreobj}; - }); - return { - update: Knockout.computed({ - read: function() { - return newScore(); - }, - write: function() { - newScore(true); - newScore(false); - } - }), - revealed: revealed, - state: state, - answered: answered, - answeredString: Knockout.computed(function() { - if((obj.marks()==0 && !obj.doesMarking()) || !(revealed() || settings.showActualMark || settings.showTotalMark)) { - return ''; - } - var key = answered() ? 'answered' : partiallyAnswered() ? 'partially answered' : 'unanswered'; - return R('question.score feedback.'+key); - },this), - attemptedString: Knockout.computed(function() { - var key = attempted() ? 'attempted' : 'unattempted'; - return R('question.score feedback.'+key); - },this), - message: Knockout.computed(function() { - var ingredients = messageIngredients(); - return R(ingredients.key,ingredients.scoreobj); - }), - plainMessage: Knockout.computed(function() { - var ingredients = messageIngredients(); - var key = ingredients.key; - if(key=='question.score feedback.score total actual' || key=='question.score feedback.score actual') { - key += '.plain'; - } - return R(key,ingredients.scoreobj); - }), - iconClass: Knockout.computed(function() { - if (!showFeedbackIcon) { - return 'invisible'; - } - switch(state()) { - case 'wrong': - return 'icon-remove'; - case 'correct': - return 'icon-ok'; - case 'partial': - return 'icon-ok partial'; - default: - return ''; - } - }), - iconAttr: Knockout.computed(function() { - return {title:state()=='none' ? '' : R('question.score feedback.'+state())}; - }) - } -}; + // References to functions in Numbas.display_util, for backwards compatibility. + measureText: display_util.measureText, -var passwordHandler = display.passwordHandler = function(settings) { - var value = Knockout.observable(''); - - var valid = Knockout.computed(function() { - return settings.accept(value()); - }); - - return { - value: value, - valid: valid, - feedback: Knockout.computed(function() { - if(valid()) { - return {iconClass: 'icon-ok', title: settings.correct_message, buttonClass: 'btn-success'}; - } else if(value()=='') { - return {iconClass: '', title: '', buttonClass: 'btn-primary'} - } else { - return {iconClass: 'icon-remove', title: settings.incorrect_message, buttonClass: 'btn-danger'}; - } - }) - }; -} + showScoreFeedback: display_util.showScoreFeedback, + + passwordHandler: display_util.passwordHandler, + + localisePage: display_util.localisePage, + + getLocalisedAttribute: display_util.getLocalisedAttribute, + +}; }); diff --git a/themes/default/files/scripts/display-util.js b/themes/default/files/scripts/display-util.js new file mode 100644 index 000000000..794693fbe --- /dev/null +++ b/themes/default/files/scripts/display-util.js @@ -0,0 +1,353 @@ +Numbas.queueScript('display-util', ['math'], function() { + /** Parse a colour in hexadecimal RGB format into separate red, green and blue components. + * + * @param {string} hex - The hex string representing the colour, in the form `#000000`. + * @returns {Array.} - An array of the form `[r,g,b]`. + */ + function parseRGB(hex) { + var r = parseInt(hex.slice(1,3)); + var g = parseInt(hex.slice(3,5)); + var b = parseInt(hex.slice(5,7)); + return [r,g,b]; + } + + /** Convert a colour given in red, green, blue components to hue, saturation, lightness. + * From https://css-tricks.com/converting-color-spaces-in-javascript/. + * + * @param {number} r - The red component. + * @param {number} g - The green component. + * @param {number} b - The blue component. + * @returns {Array.} - The colour in HSL format, an array of the form `[h,s,l]`. + * */ + function RGBToHSL(r,g,b) { + r /= 255; + g /= 255; + b /= 255; + + var cmin = Math.min(r,g,b); + var cmax = Math.max(r,g,b); + var delta = cmax - cmin; + + var h,s,l; + + if (delta == 0) { + h = 0; + } else if (cmax == r) { + h = ((g - b) / delta) % 6; + } else if (cmax == g) { + h = (b - r) / delta + 2; + } else { + h = (r - g) / delta + 4; + } + + h = (h*60) % 360; + + if (h < 0) { + h += 360; + } + + l = (cmax + cmin) / 2; + + s = delta == 0 ? 0 : delta / (1 - Math.abs(2 * l - 1)); + + return [h,s,l]; + } + + /** Convert a colour in hue, saturation, lightness format to red, green, blue. + * From https://css-tricks.com/converting-color-spaces-in-javascript/. + * + * @param {number} h - The hue component. + * @param {number} s - The saturation component. + * @param {number} l - The lightness component. + * @returns {Array.} - An array of the form `[r,g,b]`. + */ + function HSLToRGB(h,s,l) { + var c = (1 - Math.abs(2 * l - 1)) * s; + var x = c * (1 - Math.abs((h / 60) % 2 - 1)); + var m = l - c/2; + + var r,g,b; + + if (0 <= h && h < 60) { + r = c; g = x; b = 0; + } else if (60 <= h && h < 120) { + r = x; g = c; b = 0; + } else if (120 <= h && h < 180) { + r = 0; g = c; b = x; + } else if (180 <= h && h < 240) { + r = 0; g = x; b = c; + } else if (240 <= h && h < 300) { + r = x; g = 0; b = c; + } else if (300 <= h && h < 360) { + r = c; g = 0; b = x; + } + r = (r + m) * 255; + g = (g + m) * 255; + b = (b + m) * 255; + + return [r,g,b]; + } + + var measurer; + var measureText_cache = {}; + function measureText(element) { + var styles = window.getComputedStyle(element); + + if(!measurer) { + measurer = document.createElement('div'); + measurer.style['position'] = 'absolute'; + measurer.style['left'] = '-10000'; + measurer.style['top'] = '-10000'; + measurer.style['visibility'] = 'hidden'; + } + + var keys = ['font-size','font-style', 'font-weight', 'font-family', 'line-height', 'text-transform', 'letter-spacing']; + var id = element.value+';'+keys.map(function(key) { return styles[key]; }).join(';'); + if(measureText_cache[id]) { + return measureText_cache[id]; + } + keys.forEach(function(key) { + measurer.style[key] = styles[key]; + }); + measurer.textContent = element.value; + document.body.appendChild(measurer); + var box = measurer.getBoundingClientRect(); + measureText_cache[id] = box; + document.body.removeChild(measurer); + return box; + } + + /** An object which can produce feedback: {@link Numbas.Question} or {@link Numbas.parts.Part}. + * + * @typedef {object} Numbas.display_util.feedbackable + * @property {observable.} answered - Has the object been answered? + * @property {observable.} isDirty - Has the student's answer changed? + * @property {observable.} score - Number of marks awarded + * @property {observable.} marks - Number of marks available + * @property {observable.} credit - Proportion of available marks awarded + * @property {observable.} doesMarking - Does the object do any marking? + * @property {observable.} revealed - Have the correct answers been revealed? + * @property {boolean} plainScore - Show the score without the "Score: " prefix? + */ + /** Settings for {@link Numbas.display_util.showScoreFeedback} + * + * @typedef {object} Numbas.display_util.showScoreFeedback_settings + * @property {boolean} showTotalMark - Show the total marks available? + * @property {boolean} showActualMark - Show the student's current score? + * @property {boolean} showAnswerState - Show the correct/incorrect state after marking? + * @property {boolean} reviewShowScore - Show the score once answers have been revealed? + */ + /** Feedback states for a question or part: "wrong", "correct", "partial" or "none". + * + * @typedef {string} Numbas.display_util.feedback_state + */ + /** A model representing feedback on an item which is marked - a question or a part. + * + * @typedef {object} Numbas.display_util.scoreFeedback + * @property {observable.} update - Call `update(true)` when the score changes. Used to trigger animations. + * @property {observable.} state - The current state of the item, to be shown to the student. + * @property {observable.} answered - Has the item been answered? False if the student has changed their answer since submitting. + * @property {observable.} answeredString - Translated text describing how much of the item has been answered: 'unanswered', 'partially answered' or 'answered' + * @property {observable.} message - Text summarising the state of the item. + * @property {observable.} iconClass - CSS class for the feedback icon. + * @property {observable.} iconAttr - A dictionary of attributes for the feedback icon. + */ + /** Update a score feedback box. + * + * @param {Numbas.display_util.feedbackable} obj - Object to show feedback about. + * @param {Numbas.display_util.showScoreFeedback_settings} settings + * @memberof Numbas.display + * @returns {Numbas.display_util.scoreFeedback} + */ + function showScoreFeedback(obj,settings) + { + var niceNumber = Numbas.math.niceNumber; + var scoreDisplay = ''; + var newScore = Knockout.observable(false); + var answered = Knockout.computed(function() { + return obj.answered(); + }); + var attempted = Knockout.computed(function() { + return obj.visited!==undefined && obj.visited(); + }); + var showFeedbackIcon = settings.showFeedbackIcon === undefined ? settings.showAnswerState : settings.showFeedbackIcon; + var anyAnswered = Knockout.computed(function() { + if(obj.anyAnswered===undefined) { + return answered(); + } else { + return obj.anyAnswered(); + } + }); + var partiallyAnswered = Knockout.computed(function() { + return anyAnswered() && !answered(); + },this); + var revealed = Knockout.computed(function() { + return (obj.revealed() && settings.reviewShowScore) || Numbas.is_instructor; + }); + var state = Knockout.computed(function() { + var score = obj.score(); + var marks = obj.marks(); + var credit = obj.credit(); + if( obj.doesMarking() && showFeedbackIcon && (revealed() || (settings.showAnswerState && anyAnswered())) ) { + if(credit<=0) { + return 'wrong'; + } else if(Numbas.math.precround(credit,10)>=1) { + return 'correct'; + } else { + return 'partial'; + } + } + else { + return 'none'; + } + }); + var messageIngredients = ko.computed(function() { + var score = obj.score(); + var marks = obj.marks(); + var scoreobj = { + marks: marks, + score: score, + marksString: niceNumber(marks)+' '+R('mark',{count:marks}), + scoreString: niceNumber(score)+' '+R('mark',{count:score}), + }; + var messageKey; + if(marks==0) { + messageKey = 'question.score feedback.not marked'; + } else if(!revealed()) { + if(settings.showActualMark) { + if(settings.showTotalMark) { + messageKey = 'question.score feedback.score total actual'; + } else { + messageKey = 'question.score feedback.score actual'; + } + } else if(settings.showTotalMark) { + messageKey = 'question.score feedback.score total'; + } else { + var key = answered () ? 'answered' : anyAnswered() ? 'partially answered' : 'unanswered'; + messageKey = 'question.score feedback.'+key; + } + } else { + messageKey = 'question.score feedback.score total actual'; + } + return {key: messageKey, scoreobj: scoreobj}; + }); + return { + update: Knockout.computed({ + read: function() { + return newScore(); + }, + write: function() { + newScore(true); + newScore(false); + } + }), + revealed: revealed, + state: state, + answered: answered, + answeredString: Knockout.computed(function() { + if((obj.marks()==0 && !obj.doesMarking()) || !(revealed() || settings.showActualMark || settings.showTotalMark)) { + return ''; + } + var key = answered() ? 'answered' : partiallyAnswered() ? 'partially answered' : 'unanswered'; + return R('question.score feedback.'+key); + },this), + attemptedString: Knockout.computed(function() { + var key = attempted() ? 'attempted' : 'unattempted'; + return R('question.score feedback.'+key); + },this), + message: Knockout.computed(function() { + var ingredients = messageIngredients(); + return R(ingredients.key,ingredients.scoreobj); + }), + plainMessage: Knockout.computed(function() { + var ingredients = messageIngredients(); + var key = ingredients.key; + if(key=='question.score feedback.score total actual' || key=='question.score feedback.score actual') { + key += '.plain'; + } + return R(key,ingredients.scoreobj); + }), + iconClass: Knockout.computed(function() { + if (!showFeedbackIcon) { + return 'invisible'; + } + switch(state()) { + case 'wrong': + return 'icon-remove'; + case 'correct': + return 'icon-ok'; + case 'partial': + return 'icon-ok partial'; + default: + return ''; + } + }), + iconAttr: Knockout.computed(function() { + return {title:state()=='none' ? '' : R('question.score feedback.'+state())}; + }) + } + }; + + function passwordHandler(settings) { + var value = Knockout.observable(''); + + var valid = Knockout.computed(function() { + return settings.accept(value()); + }); + + return { + value: value, + valid: valid, + feedback: Knockout.computed(function() { + if(valid()) { + return {iconClass: 'icon-ok', title: settings.correct_message, buttonClass: 'btn-success'}; + } else if(value()=='') { + return {iconClass: '', title: '', buttonClass: 'btn-primary'} + } else { + return {iconClass: 'icon-remove', title: settings.incorrect_message, buttonClass: 'btn-danger'}; + } + }) + }; + } + + /** Localise strings in page HTML - for tags with an attribute `data-localise`, run that attribute through R.js to localise it, and replace the tag's HTML with the result. + */ + function localisePage() { + for(let e of document.querySelectorAll('[data-localise]')) { + const localString = R(e.getAttribute('data-localise')); + e.innerHTML = localString; + } + for(let e of document.querySelectorAll('[localise-aria-label]')) { + const localString = R(e.getAttribute('localise-aria-label')); + e.setAttribute('aria-label', localString); + } + } + + /** Get the attribute with the given name or, if it doesn't exist, look for localise-. + * If that exists, localise its value and set the desired attribute, then return it. + * + * @param {Element} elem + * @param {string} name + * @returns {string} + */ + function getLocalisedAttribute(elem, name) { + var attr_localise; + var attr = elem.getAttribute(name); + if(!attr && (attr_localise = elem.getAttribute('localise-'+name))) { + attr = R(attr_localise); + elem.setAttribute(name,attr); + } + return attr; + } + + var display_util = Numbas.display_util = { + parseRGB, + RGBToHSL, + HSLToRGB, + measureText, + showScoreFeedback, + passwordHandler, + localisePage, + getLocalisedAttribute, + }; +}); diff --git a/themes/default/files/scripts/exam-display.js b/themes/default/files/scripts/exam-display.js index 305325362..0fe6fc0de 100644 --- a/themes/default/files/scripts/exam-display.js +++ b/themes/default/files/scripts/exam-display.js @@ -1,4 +1,4 @@ -Numbas.queueScript('exam-display',['display-base','math','util','timing'],function() { +Numbas.queueScript('exam-display',['display-util', 'display-base','math','util','timing'],function() { var display = Numbas.display; var util = Numbas.util; /** Display properties of the {@link Numbas.Exam} object. @@ -400,7 +400,7 @@ Numbas.queueScript('exam-display',['display-base','math','util','timing'],functi /** * Handler for the password on the front page. */ - this.passwordHandler = display.passwordHandler({ + this.passwordHandler = Numbas.display_util.passwordHandler({ accept: password => this.exam.acceptPassword(password), correct_message: R('exam.password.correct'), incorrect_message: R('exam.password.incorrect') @@ -428,7 +428,7 @@ Numbas.queueScript('exam-display',['display-base','math','util','timing'],functi * @member {observable|string} confirmEnd * @memberof Numbas.display.ExamDisplay */ - this.confirmEndHandler = display.passwordHandler({ + this.confirmEndHandler = Numbas.display_util.passwordHandler({ accept: value => util.caselessCompare(value, R('control.confirm end.password')), correct_message: R('control.confirm end.correct'), incorrect_message: R('control.confirm end.incorrect') @@ -521,7 +521,7 @@ Numbas.queueScript('exam-display',['display-base','math','util','timing'],functi return qd.answered(); }); }); - qg.feedback = display.showScoreFeedback(qg,exam.settings); + qg.feedback = Numbas.display_util.showScoreFeedback(qg,exam.settings); return qg; }); for(var i=0; i. - * If that exists, localise its value and set the desired attribute, then return it. - * - * @param {Element} elem - * @param {string} name - * @returns {string} - */ - getLocalisedAttribute: function(elem, name) { - var attr_localise; - var attr = elem.getAttribute(name); - if(!attr && (attr_localise = elem.getAttribute('localise-'+name))) { - attr = R(attr_localise); - elem.setAttribute(name,attr); - } - return attr; - }, /** Update the progress bar when loading. */ showLoadProgress: function() @@ -69,6 +42,7 @@ var display = Numbas.display = /** @lends Numbas.display */ { // /** Callback functions for the modals. * + * @type {Object} */ modal: { ok: function() {}, @@ -84,7 +58,6 @@ var display = Numbas.display = /** @lends Numbas.display */ { this.modal.ok = fnOK; $('#alert-modal .modal-body').html(msg); $('#alert-modal').modal('show'); - $('#alert-modal .modal-footer .ok').focus(); }, /** Show a confirmation dialog box. @@ -182,126 +155,21 @@ var display = Numbas.display = /** @lends Numbas.display */ { $('#die').show(); $('#die .error .message').html(message); $('#die .error .stack').html(stack); - } -}; - -/** Parse a colour in hexadecimal RGB format into separate red, green and blue components. - * - * @param {string} hex - The hex string representing the colour, in the form `#000000`. - * @returns {Array.} - An array of the form `[r,g,b]`. - */ -function parseRGB(hex) { - var r = parseInt(hex.slice(1,3)); - var g = parseInt(hex.slice(3,5)); - var b = parseInt(hex.slice(5,7)); - return [r,g,b]; -} - -/** Convert a colour given in red, green, blue components to hue, saturation, lightness. - * From https://css-tricks.com/converting-color-spaces-in-javascript/. - * - * @param {number} r - The red component. - * @param {number} g - The green component. - * @param {number} b - The blue component. - * @returns {Array.} - The colour in HSL format, an array of the form `[h,s,l]`. - * */ -function RGBToHSL(r,g,b) { - r /= 255; - g /= 255; - b /= 255; - - var cmin = Math.min(r,g,b); - var cmax = Math.max(r,g,b); - var delta = cmax - cmin; - - var h,s,l; - - if (delta == 0) { - h = 0; - } else if (cmax == r) { - h = ((g - b) / delta) % 6; - } else if (cmax == g) { - h = (b - r) / delta + 2; - } else { - h = (r - g) / delta + 4; - } + }, - h = (h*60) % 360; + // References to functions in Numbas.display_util, for backwards compatibility. + measureText: display_util.measureText, - if (h < 0) { - h += 360; - } + showScoreFeedback: display_util.showScoreFeedback, - l = (cmax + cmin) / 2; + passwordHandler: display_util.passwordHandler, - s = delta == 0 ? 0 : delta / (1 - Math.abs(2 * l - 1)); + localisePage: display_util.localisePage, - return [h,s,l]; -} + getLocalisedAttribute: display_util.getLocalisedAttribute, -/** Convert a colour in hue, saturation, lightness format to red, green, blue. - * From https://css-tricks.com/converting-color-spaces-in-javascript/. - * - * @param {number} h - The hue component. - * @param {number} s - The saturation component. - * @param {number} l - The lightness component. - * @returns {Array.} - An array of the form `[r,g,b]`. - */ -function HSLToRGB(h,s,l) { - var c = (1 - Math.abs(2 * l - 1)) * s; - var x = c * (1 - Math.abs((h / 60) % 2 - 1)); - var m = l - c/2; - - var r,g,b; - - if (0 <= h && h < 60) { - r = c; g = x; b = 0; - } else if (60 <= h && h < 120) { - r = x; g = c; b = 0; - } else if (120 <= h && h < 180) { - r = 0; g = c; b = x; - } else if (180 <= h && h < 240) { - r = 0; g = x; b = c; - } else if (240 <= h && h < 300) { - r = x; g = 0; b = c; - } else if (300 <= h && h < 360) { - r = c; g = 0; b = x; - } - r = (r + m) * 255; - g = (g + m) * 255; - b = (b + m) * 255; - - return [r,g,b]; -} - -var measurer; -var measureText_cache = {}; -display.measureText = function(element) { - var styles = window.getComputedStyle(element); - - if(!measurer) { - measurer = document.createElement('div'); - measurer.style['position'] = 'absolute'; - measurer.style['left'] = '-10000'; - measurer.style['top'] = '-10000'; - measurer.style['visibility'] = 'hidden'; - } +}; - var keys = ['font-size','font-style', 'font-weight', 'font-family', 'line-height', 'text-transform', 'letter-spacing']; - var id = element.value+';'+keys.map(function(key) { return styles[key]; }).join(';'); - if(measureText_cache[id]) { - return measureText_cache[id]; - } - keys.forEach(function(key) { - measurer.style[key] = styles[key]; - }); - measurer.textContent = element.value; - document.body.appendChild(measurer); - var box = measurer.getBoundingClientRect(); - measureText_cache[id] = box; - document.body.removeChild(measurer); - return box; -} var WorksheetDisplay = Numbas.display.WorksheetDisplay = function() { var vm = this; @@ -434,8 +302,8 @@ var WorksheetDisplay = Numbas.display.WorksheetDisplay = function() { Knockout.computed(function() { var backgroundColour = vm.style.backgroundColour(); - var rgb = parseRGB(backgroundColour); - var hsl = RGBToHSL(rgb[0],rgb[1],rgb[2]); + var rgb = display_util.parseRGB(backgroundColour); + var hsl = display_util.RGBToHSL(rgb[0],rgb[1],rgb[2]); var oppositeBackgroundColour = hsl[2]<0.5 ? '255,255,255' : '0,0,0'; const page_margins = [vm.style.page_margins.top, vm.style.page_margins.right, vm.style.page_margins.bottom, vm.style.page_margins.left].map(o=>`${o()}mm`).join(' '); @@ -540,7 +408,7 @@ WorksheetDisplay.prototype = { }, print: function() { window.print(); - } + }, }; function GeneratedExam(offset) { @@ -580,144 +448,4 @@ function GeneratedExam(offset) { }); } - -/** An object which can produce feedback: {@link Numbas.Question} or {@link Numbas.parts.Part}. - * - * @typedef {object} Numbas.display.feedbackable - * @property {observable.} answered - Has the object been answered? - * @property {observable.} isDirty - Has the student's answer changed? - * @property {observable.} score - Number of marks awarded - * @property {observable.} marks - Number of marks available - * @property {observable.} credit - Proportion of available marks awarded - * @property {observable.} doesMarking - Does the object do any marking? - * @property {observable.} revealed - Have the correct answers been revealed? - * @property {boolean} plainScore - Show the score without the "Score: " prefix? - */ -/** Settings for {@link Numbas.display.showScoreFeedback} - * - * @typedef {object} Numbas.display.showScoreFeedback_settings - * @property {boolean} showTotalMark - Show the total marks available? - * @property {boolean} showActualMark - Show the student's current score? - * @property {boolean} showAnswerState - Show the correct/incorrect state after marking? - * @property {boolean} reviewShowScore - Show the score once answers have been revealed? - */ -/** Feedback states for a question or part: "wrong", "correct", "partial" or "none". - * - * @typedef {string} Numbas.display.feedback_state - */ -/** A model representing feedback on an item which is marked - a question or a part. - * - * @typedef {object} Numbas.display.scoreFeedback - * @property {observable.} update - Call `update(true)` when the score changes. Used to trigger animations. - * @property {observable.} state - The current state of the item, to be shown to the student. - * @property {observable.} answered - Has the item been answered? False if the student has changed their answer since submitting. - * @property {observable.} answeredString - Translated text describing how much of the item has been answered: 'unanswered', 'partially answered' or 'answered' - * @property {observable.} message - Text summarising the state of the item. - * @property {observable.} iconClass - CSS class for the feedback icon. - * @property {observable.} iconAttr - A dictionary of attributes for the feedback icon. - */ -/** Update a score feedback box. - * - * @param {Numbas.display.feedbackable} obj - Object to show feedback about. - * @param {Numbas.display.showScoreFeedback_settings} settings - * @memberof Numbas.display - * @returns {Numbas.display.scoreFeedback} - */ -var showScoreFeedback = display.showScoreFeedback = function(obj,settings) -{ - var niceNumber = Numbas.math.niceNumber; - var scoreDisplay = ''; - var newScore = Knockout.observable(false); - var answered = Knockout.computed(function() { - return false; - }); - var attempted = Knockout.computed(function() { - return false; - }); - var showFeedbackIcon = false; - var anyAnswered = Knockout.computed(function() { - return false; - }); - var partiallyAnswered = Knockout.computed(function() { - return false; - },this); - var revealed = Knockout.computed(function() { - return false; - }); - var state = Knockout.computed(function() { - return 'none'; - }); - var messageIngredients = ko.computed(function() { - var score = obj.score(); - var marks = obj.marks(); - var scoreobj = { - marks: marks, - score: score, - marksString: niceNumber(marks)+' '+R('mark',{count:marks}), - scoreString: niceNumber(score)+' '+R('mark',{count:score}), - }; - var messageKey; - if(marks==0) { - messageKey = 'question.score feedback.not marked'; - } else { - messageKey = 'question.score feedback.score total'; - } - return {key: messageKey, scoreobj: scoreobj}; - }); - return { - update: Knockout.computed({ - read: function() { - return newScore(); - }, - write: function() { - newScore(true); - newScore(false); - } - }), - revealed: revealed, - state: state, - answered: answered, - answeredString: Knockout.computed(function() { - if((obj.marks()==0 && !obj.doesMarking()) || !(revealed() || settings.showActualMark || settings.showTotalMark)) { - return ''; - } - var key = answered() ? 'answered' : partiallyAnswered() ? 'partially answered' : 'unanswered'; - return R('question.score feedback.'+key); - },this), - attemptedString: Knockout.computed(function() { - var key = attempted() ? 'attempted' : 'unattempted'; - return R('question.score feedback.'+key); - },this), - message: Knockout.computed(function() { - var ingredients = messageIngredients(); - return R(ingredients.key,ingredients.scoreobj); - }), - plainMessage: Knockout.computed(function() { - var ingredients = messageIngredients(); - var key = ingredients.key; - if(key=='question.score feedback.score total actual' || key=='question.score feedback.score actual') { - key += '.plain'; - } - return R(key,ingredients.scoreobj); - }), - iconClass: Knockout.computed(function() { - if (!showFeedbackIcon) { - return 'invisible'; - } - switch(state()) { - case 'wrong': - return 'icon-remove'; - case 'correct': - return 'icon-ok'; - case 'partial': - return 'icon-ok partial'; - default: - return ''; - } - }), - iconAttr: Knockout.computed(function() { - return {title:state()=='none' ? '' : R('question.score feedback.'+state())}; - }) - } -}; });