commit 24fa9a2d779034aa096bb4f521f23d0e5df0fc4d Author: Christian Lawson-Perfect Date: Sun Feb 9 20:17:15 2025 +0000 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..289789f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.make.* +elm-stuff/ +error.txt +*.exam +app.js +numbas.js \ No newline at end of file diff --git a/.watchmakerc b/.watchmakerc new file mode 100644 index 0000000..285f521 --- /dev/null +++ b/.watchmakerc @@ -0,0 +1,2 @@ +extensions: + - .elm \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..092ea03 --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +DIRNAME=$(notdir $(CURDIR)) + +ELMS=$(wildcard src/*.elm) + +app.js: src/App.elm $(ELMS) + -elm make $< --output=$@ 2> error.txt + @cat error.txt + +upload: app.js index.html style.css + rsync -avz . clpland:~/domains/somethingorotherwhatever.com/html/$(DIRNAME) + @echo "Uploaded to https://somethingorotherwhatever.com/$(DIRNAME)" \ No newline at end of file diff --git a/TODO b/TODO new file mode 100644 index 0000000..f7ca637 --- /dev/null +++ b/TODO @@ -0,0 +1 @@ +* Correctly do MathJax inside custom elements so the sizing is correct and the CSS is copied properly. \ No newline at end of file diff --git a/demo.mjs b/demo.mjs new file mode 100644 index 0000000..bbea524 --- /dev/null +++ b/demo.mjs @@ -0,0 +1,4 @@ +import './load-app.js'; + +numbas_embedder.add_exam('lots-of-instances', 'exam-1009-lots-of-instances-theme.exam'); +numbas_embedder.add_exam('logarithms', 'https://numbas.mathcentre.ac.uk/exam/5754/logarithms.exam'); \ No newline at end of file diff --git a/elm.json b/elm.json new file mode 100644 index 0000000..a3b2d2b --- /dev/null +++ b/elm.json @@ -0,0 +1,25 @@ +{ + "type": "application", + "source-directories": [ + "src" + ], + "elm-version": "0.19.1", + "dependencies": { + "direct": { + "NoRedInk/elm-json-decode-pipeline": "1.0.1", + "elm/browser": "1.0.2", + "elm/core": "1.0.5", + "elm/html": "1.0.0", + "elm/json": "1.1.3" + }, + "indirect": { + "elm/time": "1.0.0", + "elm/url": "1.0.0", + "elm/virtual-dom": "1.0.3" + } + }, + "test-dependencies": { + "direct": {}, + "indirect": {} + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..3223796 --- /dev/null +++ b/index.html @@ -0,0 +1,45 @@ + + + + + + Numbas Elm theme + + + + + + + + + + + + + + + + + + + +
+
+

Logarithms exam

+ + +
+ +
+

Lots of instances exam

+

Question 1

+ +
+ +
+ + + + \ No newline at end of file diff --git a/load-app.js b/load-app.js new file mode 100644 index 0000000..85a72ed --- /dev/null +++ b/load-app.js @@ -0,0 +1,136 @@ +import show_error from './show-error.mjs'; +console.clear(); + +(async () => { + const compilation_error = await show_error; + if(compilation_error) { + return; + } +}); + +class NumbasEmbedder { + constructor() { + this.exams = {}; + + this.numbas_promise = new Promise(resolve => { + Numbas.queueScript('go', ['start-exam', 'display'], async () => { + Numbas.display.init(); + resolve(Numbas); + }) + }) + } + + get_exam(id) { + if(!this.exams[id]) { + const exam_ref = this.exams[id] = {} + exam_ref.promise = new Promise((resolve,reject) => { + exam_ref.resolve = resolve; + exam_ref.reject = reject; + }); + } + + return this.exams[id]; + } + + async add_exam(id, source_url) { + console.log('add exam',id, source_url); + await this.numbas_promise; + console.log('numbas loaded', id); + const res = await fetch(source_url); + const exam_file = await res.text(); + const exam_json = JSON.parse(exam_file.slice(exam_file.indexOf('\n'))) + const exam = window.exam = Numbas.createExamFromJSON(exam_json); + + exam.display_id = id; + exam.init(); + + exam.signals.on(['ready', 'display question list initialised'], () => { + console.info('exam ready', id); + Numbas.signals.trigger('exam ready'); + exam.begin(); + + const exam_promise = this.get_exam(id); + console.log("LOADED", id); + exam_promise.resolve(exam); + }); + } +} + +const numbas_embedder = window.numbas_embedder = new NumbasEmbedder(); + +class ElmHTMLElement extends HTMLElement { + constructor() { + super(); + this.attachShadow({mode:'open'}); + } + static get observedAttributes() { return ['html'] }; + + attributeChangedCallback(name, oldValue, newValue) { + if(name == 'html') { + this.html = newValue; + } + } + + set html(value) { + if(typeof value == 'string') { + this.shadowRoot.innerHTML = value; + } else { + this.shadowRoot.innerHTML = ''; + this.shadowRoot.append(value); + } + } +} +customElements.define('elm-html', ElmHTMLElement); + +class NumbasPartElement extends HTMLElement { + constructor() { + super(); + + this.attachShadow({mode:'open'}); + + const style = document.createElement('link'); + style.rel = 'stylesheet'; + style.href = 'numbas-part.css'; + this.shadowRoot.append(style); + + this.init_app(); + } + + async init_app() { + const exam_id = this.getAttribute('exam'); + const mode = this.getAttribute('mode'); + const questionNumber = parseInt(this.getAttribute('question')); + const partPath = this.getAttribute('part'); + + const exam = await numbas_embedder.get_exam(exam_id).promise; + const container = document.createElement('div'); + this.shadowRoot.append(container); + const app = Elm.App.init({ + node: container, + flags: {exam: exam, view_mode: {mode, questionNumber, partPath}} + }); + + + const message_handlers = { + 'question': ({questionNumber, msg}) => { + const question = exam.questionList[questionNumber]; + question.display.handle_message(msg); + } + } + + app.ports.sendMessage.subscribe(data => { + const fn = message_handlers[data.msgtype]; + if(fn) { + fn(data); + } + }) + + exam.events.on('update app', ({type, arg}) => { + //type != 'showTiming' && console.log('update app', {type,arg}); + app.ports.receiveMessage.send({type, arg}); + }); + + } +} + +customElements.define('numbas-part', NumbasPartElement); \ No newline at end of file diff --git a/mathjax-config.js b/mathjax-config.js new file mode 100644 index 0000000..b3eae9e --- /dev/null +++ b/mathjax-config.js @@ -0,0 +1,10 @@ +MathJax = { + tex: { + inlineMath: [['$', '$'], ['\\(', '\\)']], + packages: {'[+]': ['numbas']} + }, + loader: { + load: ['[custom]/numbas-mathjax.js'], + paths: {custom: '.'} + } +}; \ No newline at end of file diff --git a/numbas-elm-display.js b/numbas-elm-display.js new file mode 100644 index 0000000..81885d4 --- /dev/null +++ b/numbas-elm-display.js @@ -0,0 +1,1060 @@ +Numbas.queueScript('part-display',['display-util', 'display-base','util','jme'],function() { + var display = Numbas.display; + var extend = Numbas.util.extend; + var util = Numbas.util; + + class PartDisplay { + constructor(p) { + this.part = p; + + let parentPart = p; + while(parentPart.parentPart) { + parentPart = parentPart.parentPart; + } + this.parentPart = parentPart; + + this.message_handlers = { + 'part_answer': ({answer}) => this.part.storeAnswer(answer), + 'submit': () => { + this.parentPart.submit(); + } + }; + + let prompt_string = this.part.json.prompt; + if(this.part.type == 'gapfill') { + prompt_string = prompt_string.replace( + /\[\[(\d+)\]\]/g, + (m,n) => `` + ); + } + this.prompt = display.makeHTMLFromString(prompt_string, this.part.getScope()); + } + + update_app(type, arg) { + this.part.events.trigger('update app',{type, arg, partPath: this.part.path}); + } + + handle_message(data) { + const {msgtype} = data; + const fn = this.message_handlers[msgtype]; + if(fn) { + fn(data); + } + } + + isDirty(dirty) { + this.update_app('isDirty', dirty); + } + + show() { + this.update_app('show'); + } + + updateNextParts() { + this.update_app('updateNextParts'); + } + + setName(name) { + this.update_app('setName', name); + } + + warning(warning) { + this.update_app('warning', warning); + } + + setWarnings(warnings) { + this.update_app('setWarnings', warnings); + } + + waiting_for_pre_submit(waiting) { + this.update_app('waiting_for_pre_submit', waiting); + } + + showScore(answered) { + this.update_app('showScore', answered); + } + + updateCorrectAnswer(answer) { + this.update_app('updateCorrectAnswer', answer); + } + + showSteps() { + this.update_app('showSteps'); + } + + hideSteps() { + this.update_app('hideSteps'); + } + + revealAnswer() { + this.update_app('revealAnswer'); + } + + lock() { + this.update_app('lock'); + } + + end() { + this.update_app('end'); + } + + restoreAnswer() { + this.update_app('restoreAnswer'); + } + } + + display.PartDisplay = PartDisplay; +}); +Numbas.queueScript('question-display',['display-util', 'display-base','jme-variables','xml','schedule','jme','util'],function() { + var display = Numbas.display; + + class QuestionDisplay { + constructor(question) { + this.question = question; + + this.message_handlers = { + 'part': ({partPath, msg}) => { + const part = this.question.getPart(partPath); + part.display.handle_message(msg); + } + } + + this.allParts = this.question.allParts(); + } + + update_app(type, arg) { + //console.log('question update app',this.question.number, {type,arg}); + this.question.events.trigger('update app', {type, arg, questionNumber: this.question.number}); + } + + handle_message(data) { + const {msgtype} = data; + const fn = this.message_handlers[msgtype]; + if(fn) { + fn(data); + } + } + + isDirty(dirty) { + this.update_app('isDirty',dirty); + } + + addPart(part) { + part.events.on('update app', evt => this.update_app('part', evt)); + this.allParts = this.question.allParts(); + } + + removePart(part) { + } + + resume() { + } + + updateParts() { + } + + end() { + } + + getPart(path) { + } + + init() { + } + + leave() { + } + + makeHTML() { + this.statement = display.makeHTMLFromString(this.question.json.statement, this.question.getScope()); + + this.question.signals.trigger('mainHTMLAttached'); + } + + revealAnswer() { + } + + review() { + } + + show() { + } + + showAdvice() { + } + + showScore() { + this.update_app('showScore'); + } + } + + display.QuestionDisplay = QuestionDisplay; +}) +; +Numbas.queueScript('exam-display',['display-util', 'display-base','math','util','timing'],function() { + var display = Numbas.display; + var util = Numbas.util; + + class ExamDisplay { + /** + * @param {Numbas.Exam} exam + */ + constructor(exam) { + this.exam = exam; + Numbas.signals.trigger('display ready'); + } + + update_app(type, arg) { + //console.log('exam update app', {type,arg}); + this.exam.events.trigger('update app', {type, arg}); + } + + /** Update the timer. + * + * @memberof Numbas.display.ExamDisplay + */ + showTiming() { + //this.update_app('showTiming'); + } + + /** Initialise the question list display. + * + * @memberof Numbas.display.ExamDisplay + */ + initQuestionList() { + this.update_app('initQuestionList'); + } + + addQuestion(question) { + question.events.on('update app', (msg) => this.update_app('question',msg)); + this.updateQuestionList(); + } + + /** Update the list of questions after a change. + */ + updateQuestionList() { + this.update_app('updateQuestionList'); + } + + /** Hide the timer. + * + * @memberof Numbas.display.ExamDisplay + */ + hideTiming() { + this.update_app('hideTiming'); + } + + /** Show/update the student's total score. + * + * @memberof Numbas.display.ExamDisplay + */ + showScore() { + this.update_app('showScore'); + } + + /** Show an info page (one of the front page, pause, or results). + * + * @param {string} page - Name of the page to show. + * @memberof Numbas.display.ExamDisplay + */ + showInfoPage(page) { + this.update_app('showInfoPage',page); + } + + /** Show the modal dialog with actions the student can take to move on from the current question. + */ + showDiagnosticActions() { + this.update_app('showDiagnosticActions'); + } + + /** Show the current question. + * + * @memberof Numbas.display.ExamDisplay + */ + showQuestion() { + this.update_app('showQuestion'); + } + + /** Called just before the current question is regenerated. + * + * @memberof Numbas.display.ExamDisplay + */ + startRegen() { + this.update_app('startRegen'); + } + /** Called after the current question has been regenerated. + * + * @memberof Numbas.display.ExamDisplay + */ + endRegen() { + this.update_app('endRegen'); + } + + /** Reveal the answers to every question in the exam. + * + * @memberof Numbas.display.ExamDisplay + */ + revealAnswers() { + this.update_app('revealAnswers'); + } + + /** Called when the exam ends. + * + * @memberof Numbas.display.ExamDisplay + */ + end() { + this.update_app('end'); + } + } + + display.ExamDisplay = ExamDisplay; +}); +; +Numbas.queueScript('mathjax-hooks',['display-base','jme','jme-display'],function() { +/* + if(typeof MathJax=='undefined') { + return; + } + var jme = Numbas.jme; + MathJax.Hub.Register.StartupHook("TeX Jax Ready",function () { + var TEX = MathJax.InputJax.TeX; + var currentScope = null; + TEX.prefilterHooks.Add(function(data) { + currentScope = Numbas.display.find_jme_scope(data.script); + }); + TEX.Definitions.Add({macros: { + 'var': 'JMEvar', + 'simplify': 'JMEsimplify' + }}); + TEX.Parse.Augment({ + JMEvar: function(name) { + var settings_string = this.GetBrackets(name); + var settings = {}; + if(settings_string!==undefined) { + settings_string.split(/\s*,\s* /g).forEach(function(v) { + var setting = jme.normaliseRulesetName(v.trim()); + settings[setting] = true; + }); + } + var expr = this.GetArgument(name); + var scope = currentScope; + try { + var v = jme.evaluate(jme.compile(expr,scope),scope); + var tex = jme.display.texify({tok: v},settings,scope); + }catch(e) { + throw(new Numbas.Error('mathjax.math processing error',{message:e.message,expression:expr})); + } + var mml = TEX.Parse(tex,this.stack.env).mml(); + this.Push(mml); + }, + JMEsimplify: function(name) { + var ruleset = this.GetBrackets(name); + if(ruleset === undefined) { + ruleset = 'all'; + } + var expr = this.GetArgument(name); + var scope = currentScope; + + var subbed_tree = Numbas.jme.display.subvars(expr, scope); + + try { + var tex = Numbas.jme.display.treeToLaTeX(subbed_tree, ruleset, scope); + } catch(e) { + throw(new Numbas.Error('mathjax.math processing error',{message:e.message,expression:expr})); + } + + var mml = TEX.Parse(tex,this.stack.env).mml(); + this.Push(mml); + } + }) + }); + */ +}); +Numbas.queueScript('display/parts/custom',['display-base','part-display','util','jme'],function() { + var display = Numbas.display; + var extend = Numbas.util.extend; + + class CustomPartDisplay extends Numbas.display.PartDisplay { + } + + Numbas.display.CustomPartDisplay = CustomPartDisplay; + +}); +Numbas.queueScript('display/parts/extension',['display-base','part-display','util'],function() { + var display = Numbas.display; + var extend = Numbas.util.extend; + + class ExtensionPartDisplay extends Numbas.display.PartDisplay { + } + + Numbas.display.ExtensionPartDisplay = ExtensionPartDisplay; + +}); +Numbas.queueScript('display/parts/gapfill',['display-base','part-display','util'],function() { + var display = Numbas.display; + var extend = Numbas.util.extend; + + class GapFillPartDisplay extends Numbas.display.PartDisplay { + } + + Numbas.display.GapFillPartDisplay = GapFillPartDisplay; + +}); +Numbas.queueScript('display/parts/information',['display-base','part-display','util'],function() { + var display = Numbas.display; + var extend = Numbas.util.extend; + + class InformationPartDisplay extends Numbas.display.PartDisplay { + } + + Numbas.display.InformationPartDisplay = InformationPartDisplay; + +}); +; +Numbas.queueScript('display/parts/jme',['display-base','part-display','util','jme-display','jme'],function() { + var display = Numbas.display; + var extend = Numbas.util.extend; + var jme = Numbas.jme; + + class JMEPartDisplay extends Numbas.display.PartDisplay { + } + + Numbas.display.JMEPartDisplay = JMEPartDisplay; +}) +Numbas.queueScript('display/parts/matrix',['display-base','part-display','util','jme','jme-display'],function() { + var display = Numbas.display; + var extend = Numbas.util.extend; + var util = Numbas.util; + + class MatrixEntryPartDisplay extends Numbas.display.PartDisplay { + } + + Numbas.display.MatrixEntryPartDisplay = MatrixEntryPartDisplay; + +}); +Numbas.queueScript('display/parts/multipleresponse',['display-base','part-display','util'],function() { + var display = Numbas.display; + var extend = Numbas.util.extend; + var util = Numbas.util; + + class MultipleResponsePartDisplay extends Numbas.display.PartDisplay { + } + + Numbas.display.MultipleResponsePartDisplay = MultipleResponsePartDisplay; + +}); +Numbas.queueScript('display/parts/numberentry',['display-base','part-display','util'],function() { + var display = Numbas.display; + var extend = Numbas.util.extend; + var util = Numbas.util; + + class NumberEntryPartDisplay extends Numbas.display.PartDisplay { + } + + Numbas.display.NumberEntryPartDisplay = NumberEntryPartDisplay; +}); +Numbas.queueScript('display/parts/patternmatch',['display-base','part-display','util'],function() { + var display = Numbas.display; + var extend = Numbas.util.extend; + + class PatternMatchPartDisplay extends Numbas.display.PartDisplay { + } + + Numbas.display.PatternMatchPartDisplay = PatternMatchPartDisplay; +}); +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 */ { + /** Update the progress bar when loading. + */ + showLoadProgress: function() { + }, + + makeHTMLFromString(html, scope, contextDescription) { + try { + + const element = document.createElement('div'); + element.innerHTML = html; + Numbas.xml.localise(element); + display.setJMEScope(element, scope); + if(!element.getAttribute('data-jme-context-description')) { + element.setAttribute('data-jme-context-description',contextDescription); + } + + const subbed_element = Numbas.jme.variables.DOMcontentsubvars(element,scope); + + Numbas.display.register_lightbox(subbed_element); + Numbas.display.typeset(subbed_element); + return subbed_element; + + } catch(error) { + console.error(error); + throw(error); + + var errorContextDescriptionBits = []; + var errorContextDescription; + if(error.element) { + var elem = error.element; + while(elem) { + if(elem.nodeType==1) { + var desc = Numbas.display.getLocalisedAttribute(elem,'data-jme-context-description'); + if(desc) { + errorContextDescriptionBits.splice(0,0,desc); + } + } + elem = elem.parentElement; + } + errorContextDescription = errorContextDescriptionBits.join(' '); + } else { + errorContextDescription = contextDescription; + } + Numbas.schedule.halt(new Numbas.Error('display.error making html',{contextDescription: errorContextDescription, message: error.message},error)); + } + }, + + + /** + * Find the element's top ancestor node. For elements in the document, this will be the document object itself. + * + * @param {Element} element + * @returns {Node} + */ + find_root_ancestor: function(element) { + while(element.parentNode) { + element = element.parentNode; + } + return element; + }, + + + typeset: function(element,callback) { + if(!element) { + element = document.body; + } + let elements = [element]; + + var tries = 0; + var delay = 10; + + const all_elements = elements.slice(); + /** + * Try to typeset the given elements. + * An element is typeset if it is attached to the main document, and has a parent which specifies a JME scope to use. + * + * After each attempt, if there are any elements still waiting to be typeset, there's an exponentially growing delay before trying again. + * + * Once all elements have been typeset, the callback is called. + */ + async function try_to_typeset() { + try { + elements = elements.filter(element => { + var root = display.find_root_ancestor(element); + if(root.ownerDocument !== document) { + return true; + } + + var scope = display.find_jme_scope(element); + if(!scope) { + return true; + } + + return false; + }); + + if(elements.length) { + delay *= 1.1; + setTimeout(try_to_typeset, delay); + } else { + await MathJax.typesetPromise(all_elements); + all_elements.forEach(e => { + const root = e.getRootNode(); + const s = document.getElementById('MJX-SVG-styles'); + if(!s) { + return; + } + let css_tag = root.querySelector('#MJX-SVG-styles'); + if(!css_tag) { + css_tag = s.cloneNode(true); + root.append(css_tag); + } + css_tag.textContent = s.textContent; + }) + if(callback) { + callback(elements); + } + } + } catch(e) { + if(window.MathJax===undefined && !display.failedMathJax) { + display.failedMathJax = true; + display.showAlert("Failed to load MathJax. Maths will not be typeset properly.\n\nIf you are the exam author, please check that you are connected to the internet, or modify the theme to load a local copy of MathJax. Instructions for doing this are given in the manual."); + } else { + Numbas.schedule.halt(e); + } + } + } + + setTimeout(try_to_typeset, 100); + }, + + register_lightbox() { + + }, + + /** Initialise the display. Called as soon as the page loads. + */ + init: function() { + }, + + //alert / confirm boxes + // + /** Callback functions for the modals. + * + * @type {Object} + */ + modal: { + ok: function() {}, + cancel: function() {} + }, + + /** Show an alert dialog. + * + * @param {string} msg - message to show the user + * @param {Function} fnOK - callback when OK is clicked + */ + showAlert: function(msg,fnOK) { + }, + + /** Show a confirmation dialog box. + * + * @param {string} msg - message to show the user + * @param {Function} fnOK - callback if OK is clicked + * @param {Function} fnCancel - callback if cancelled + */ + showConfirm: function(msg,fnOK,fnCancel) { + }, + + /** Show the end exam confirmation dialog box. + * + * @param {string} msg - message to show the user + * @param {Function} fnEnd - callback to end the exam + * @param {Function} fnCancel - callback if cancelled + */ + showConfirmEndExam: function(msg,fnEnd,fnCancel) { + }, + + /** + * Find the JME scope that applies to this element. + * Looks for an element with a `'jme_scope'` attribute. + * + * @param {Element} element + * @returns {Numbas.jme.Scope} + */ + find_jme_scope: function(element) { + while(element && !element.jme_scope) { + if(!element.parentElement) { + const root = element.getRootNode(); + if(root !== document) { + element = root.host; + } + } else { + element = element.parentElement; + } + } + return element ? element.jme_scope : null; + }, + + /** Associate a JME scope with the given element. + * + * @param {Element} element + * @param {Numbas.jme.Scope} scope + */ + setJMEScope: function(element, scope) { + element.classList.add('jme-scope'); + element.jme_scope = scope; + }, + + /** The Numbas exam has failed so much it can't continue - show an error message and the error. + * + * @param {Error} e + */ + die: function(e) { + }, +}; + +}); +; + +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? + * @property {boolean} [reveal_answers_for_instructor=true] - When `Numbas.is_instructor` is true, always act as if the object has 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 && settings.reveal_answers_for_instructor!==false); + }); + 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, + }; +}); + +/* +Copyright 2011-16 Newcastle University + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +/** @file Display code. Provides {@link Numbas.display} */ +Numbas.queueScript('display',[],function() { +}); \ No newline at end of file diff --git a/numbas-mathjax.js b/numbas-mathjax.js new file mode 100644 index 0000000..33b2c87 --- /dev/null +++ b/numbas-mathjax.js @@ -0,0 +1,64 @@ +/** + * An extension to MathJax which adds \var and \simplify commands. + */ + + +(function() { + +var NumbasMap = new MathJax._.input.tex.SymbolMap.CommandMap( + 'numbasMap', + + { + var: ['numbasVar', 'var'], + simplify: ['numbasToken', 'simplify'] + }, + + { + numbasVar: function mmlToken(parser, name, type) { + const {jme} = Numbas; + + try { + const settings_string = parser.GetBrackets(name); // The optional argument to the command, in square brackets. + + const settings = {}; + if(settings_string!==undefined) { + settings_string.split(/\s*,\s*/g).forEach(function(v) { + var setting = jme.normaliseRulesetName(v.trim()); + settings[setting] = true; + }); + } + + const expr = parser.GetArgument(name); + + const {scope} = parser.configuration.packageData.get('numbas'); + + const tok = jme.evaluate(expr, scope); + const tex = jme.display.texify({tok}, settings, scope); + const mml = new MathJax._.input.tex.TexParser.default(tex, parser.stack.env, parser.configuration).mml(); + + parser.Push(mml); + } catch(e) { + console.error(e); + throw(new Numbas.Error('mathjax.math processing error',{message:e.message,expression:expr})); + } + + } + } +); + +function saveJMEScope(arg) { + const scope = Numbas.display.find_jme_scope(arg.math.start.node); + arg.data.packageData.set('numbas',{scope}); +} + + +var NumbasConfiguration = MathJax._.input.tex.Configuration.Configuration.create('numbas', { + handler: { + macro: ['numbasMap'] + }, + preprocessors: [ + [saveJMEScope, 1] + ], +}); + +})(); \ No newline at end of file diff --git a/numbas-part.css b/numbas-part.css new file mode 100644 index 0000000..26c703d --- /dev/null +++ b/numbas-part.css @@ -0,0 +1,3 @@ +form { + display: inline; +} \ No newline at end of file diff --git a/show-error.mjs b/show-error.mjs new file mode 100644 index 0000000..0c9d53e --- /dev/null +++ b/show-error.mjs @@ -0,0 +1,21 @@ +export default fetch('/error.txt').then(r=>{ + if(r.ok) { + return r.text(); + } else { + throw(''); + } +}).then(text => { + if(!text) { + return false; + } + document.body.innerHTML = ''; + const error_show = document.createElement('pre'); + error_show.setAttribute('id','build-error'); + error_show.style.background = 'black'; + error_show.style.color = 'white'; + error_show.style.padding = '1em'; + error_show.style['font-size'] = '16px'; + error_show.textContent = text; + document.body.appendChild(error_show); + return true; +}).catch(e => false); diff --git a/src/App.elm b/src/App.elm new file mode 100644 index 0000000..5df3998 --- /dev/null +++ b/src/App.elm @@ -0,0 +1,479 @@ +port module App exposing (..) + +import Browser +import Dict exposing (Dict) +import Html as H exposing (Html) +import Html.Keyed +import Html.Attributes as HA +import Html.Events as HE +import Json.Decode as JD +import Json.Decode.Pipeline as JDP exposing (required, requiredAt) +import Json.Encode as JE exposing (Value) +import Tuple exposing (pair, first, second) + +port sendMessage : Value -> Cmd msg +port receiveMessage : (Value -> msg) -> Sub msg +port receiveClick : (Value -> msg) -> Sub msg + +main = Browser.element + { init = init + , update = update + , subscriptions = subscriptions + , view = view + } + +type alias Exam = + { object : JE.Value + , currentQuestion : Int + , questions : List Question + } + +type alias Question = + { isDirty : Bool + , score : Float + , object : JE.Value + , name : String + , parts : List Part + , number : Int + , statement : JE.Value + } + +type alias QuestionMessage = + { questionNumber : Int + , type_ : String + , msg : JE.Value + } + +type PartType + = InformationOnlyPart + | NumberEntryPart + | MathematicalExpressionPart + | PatternMatchPart + | GapFillPart + | CustomPart String + +type alias Part = + { object : JE.Value + , name : String + , type_ : PartType + , path : String + , score : Float + , marks: Float + , prompt : JE.Value + , marking_feedback : List FeedbackMessage + , gaps : Gaps + } + +type Gaps = Gaps (List Part) + +type alias FeedbackMessage = + { op : String + , credit : Maybe Float + , message : JE.Value + , reason : Maybe String + , credit_change : Maybe String + } + +type alias PartMessage = + { partPath : String + , type_ : String + , msg : JE.Value + } + +type ViewMode + = ViewWholeExam + | ViewQuestion Int + | ViewPart Int String + | ViewPartAnswer Int String + +decode_default : JD.Decoder a -> a -> JE.Value -> a +decode_default decoder default = + JD.decodeValue decoder >> Result.withDefault default + +decode_view_mode = + JD.field "mode" JD.string + |> JD.andThen (\mode -> case mode of + "exam" -> JD.succeed ViewWholeExam + "question" -> JD.field "questionNumber" JD.int |> JD.map ViewQuestion + "part" -> + JD.map2 + ViewPart + (JD.field "questionNumber" JD.int) + (JD.field "partPath" JD.string) + "part_answer" -> + JD.map2 + ViewPartAnswer + (JD.field "questionNumber" JD.int) + (JD.field "partPath" JD.string) + _ -> JD.fail <| "Unrecognised view mode: "++mode + ) + +type alias Model = + { exam : Maybe Exam + , messages : List String + , view_mode : ViewMode + } + +init_model : JE.Value -> Model +init_model flags = + let + exam = + JD.decodeValue (JD.field "exam" JD.value) flags + |> Result.toMaybe + |> Maybe.map (\e -> + { object = e + , currentQuestion = -1 + , questions = get_questions e + } + ) + + view_mode = + JD.decodeValue + (JD.field "view_mode" decode_view_mode) + flags + |> Result.withDefault ViewWholeExam + in + { exam = exam + , messages = [] + , view_mode = view_mode + } + +type Msg + = ReceiveMessage Value + | SendPartMessage Question Part String (List (String, JE.Value)) + +init : JE.Value -> (Model, Cmd msg) +init flags = (init_model flags, Cmd.none) + +listGet : Int -> List a -> Maybe a +listGet i = List.drop i >> List.head + +decode_message = + JD.map2 pair + (JD.field "type" JD.string) + (JD.field "arg" JD.value) + +update msg model = case msg of + ReceiveMessage value -> + case JD.decodeValue decode_message value of + Ok (kind,arg) -> ({ model | messages = if kind /= "showTiming" then kind::model.messages else model.messages } |> handle_exam_message kind arg, Cmd.none) + Err s -> ({ model | messages = (Debug.toString s)::model.messages }, Cmd.none) + + SendPartMessage question part msgtype extras -> + (model, sendMessage (encode_part_message question part msgtype extras)) + +encode_message msgtype extras = + JE.object + ([("msgtype", JE.string msgtype)]++extras) + +encode_question_message question msgtype extras = + encode_message "question" [ ("msg", JE.object ([("msgtype", JE.string msgtype)]++extras)), ("questionNumber", JE.int question.number) ] + +encode_part_message question part msgtype extras = + encode_question_message question "part" [ ("msg", JE.object ([("msgtype", JE.string msgtype)]++extras)), ("partPath", JE.string part.path)] + +{- update the ith item in a list using the given map function -} +update_list : Int -> (a -> a) -> List a -> List a +update_list i fn list = list |> List.indexedMap pair |> List.map (\(j,a) -> if i==j then fn a else a) + +map_exam : (Exam -> Exam) -> Model -> Model +map_exam fn model = { model | exam = Maybe.map fn model.exam } + +map_question : Int -> (Question -> Question) -> Model -> Model +map_question i fn model = model |> map_exam (\exam -> { exam | questions = update_list i fn exam.questions }) + +map_part : String -> (Part -> Part) -> Question -> Question +map_part path fn question = { question | parts = List.map (\p -> if p.path==path then fn p else p) question.parts } + +show_question : Model -> Model +show_question model = + case model.exam of + Nothing -> model + Just exam -> case JD.decodeValue (JD.field "currentQuestionNumber" JD.int) exam.object of + Ok n -> + let + nexam = { exam | currentQuestion = n } + in + {model | exam = Just nexam} + Err _ -> model + +update_question_list = map_exam (\e -> {e | questions = get_questions e.object }) + +type alias MessageHandler m = String -> Value -> m -> m + +handle_message : Dict String (Value -> m -> m) -> MessageHandler m +handle_message message_handlers kind value model = + let + handler = Dict.get kind message_handlers |> Maybe.withDefault (\_ -> identity) + in + handler value model + +handle_exam_message : MessageHandler Model +handle_exam_message = handle_message <| Dict.fromList + [ ("showQuestion", \_ -> show_question) + , ("updateQuestionList", \_ -> update_question_list) + , ("question", \value model -> + let + mexam : Maybe Exam + mexam = model.exam + + q = Debug.log "question message" minfo + + minfo = value |> JD.decodeValue + (JD.map3 QuestionMessage + (JD.field "questionNumber" JD.int) + (JD.field "type" JD.string) + (JD.field "arg" JD.value) + ) + |> Result.toMaybe + + mquestion : Maybe Question + mquestion = Maybe.map2 get_question mexam (Maybe.map .questionNumber minfo) |> Maybe.andThen identity + in + case (minfo, mexam) of + (Just info, Just exam) -> map_question info.questionNumber (handle_question_message info.type_ info.msg) model + _ -> model + ) + ] + +handle_question_message : MessageHandler Question +handle_question_message = handle_message <| Dict.fromList + [ ("isDirty", \v q -> + v |> JD.decodeValue (JD.bool) |> Result.map (\dirty -> {q | isDirty = dirty}) |> Result.withDefault q + ) + , ("showScore", \_ q -> {q | score = JD.decodeValue (JD.field "score" JD.float) q.object |> Result.withDefault q.score }) + , ("part", \value question -> + let + minfo = value |> JD.decodeValue + (JD.map3 PartMessage + (JD.field "partPath" JD.string) + (JD.field "type" JD.string) + (JD.field "arg" JD.value) + ) + |> Result.toMaybe + + q = Debug.log "part message" minfo + + mpart = Maybe.map (.partPath >> get_part question) minfo + in + case (minfo, mpart) of + (Just info, Just part) -> map_part info.partPath (handle_part_message info.type_ info.msg) question + _ -> question + ) + ] + +handle_part_message : MessageHandler Part +handle_part_message = handle_message <| Dict.fromList + [ ("showScore", \_ p -> + let + score = decode_default (JD.field "score" JD.float) 0 p.object + marks = decode_default (JD.field "marks" JD.float) 0 p.object + marking_feedback = decode_default (JD.field "markingFeedback" (JD.list decode_feedback_message)) [] p.object + in + {p | score = score, marks = marks, marking_feedback = marking_feedback }) + ] + +subscriptions model = + Sub.batch + [ receiveMessage ReceiveMessage + ] + +get_exam_name : Exam -> String +get_exam_name = .object >> JD.decodeValue (JD.at ["settings","name"] JD.string) >> Result.withDefault "Unnamed exam" + +get_questions : JE.Value -> List Question +get_questions = JD.decodeValue (JD.at ["questionList"] (JD.list decode_question)) >> Result.withDefault [] + +get_question : Exam -> Int -> Maybe Question +get_question exam n = listGet n exam.questions + +get_part : Question -> String -> Maybe Part +get_part question path = question.parts |> List.filter (\p -> p.path == path) |> List.head + +decode_question : JD.Decoder Question +decode_question = + JD.succeed Question + |> JDP.hardcoded False + |> JDP.hardcoded 0 + |> JDP.custom JD.value + |> required "name" JD.string + |> requiredAt ["display", "allParts"] (JD.list decode_part) + |> required "number" JD.int + |> JDP.optionalAt ["display", "statement"] JD.value (JE.string "") + +decode_part : JD.Decoder Part +decode_part = + JD.succeed Part + |> JDP.custom JD.value + |> required "name" JD.string + |> required "type" decode_part_type + |> required "path" JD.string + |> required "score" JD.float + |> required "marks" JD.float + |> JDP.optionalAt ["display", "prompt"] JD.value (JE.string "") + |> JDP.optional "markingFeedback" (JD.list decode_feedback_message) [] + |> JDP.optional "gaps" (JD.map Gaps <| JD.list (JD.lazy (\_ -> decode_part))) (Gaps []) + +part_types : Dict String PartType +part_types = Dict.fromList + [ ("information", InformationOnlyPart) + , ("numberentry", NumberEntryPart) + , ("jme", MathematicalExpressionPart) + , ("patternmatch", PatternMatchPart) + ] + +decode_part_type : JD.Decoder PartType +decode_part_type = + JD.string |> JD.map (\t -> Dict.get t part_types |> Maybe.withDefault (CustomPart t)) + +decode_feedback_message = + JD.succeed FeedbackMessage + |> required "op" JD.string + |> JDP.optional "credit" (JD.maybe JD.float) Nothing + |> required "message" JD.value + |> JDP.optional "reason" (JD.maybe JD.string) Nothing + |> JDP.optional "credit_change" (JD.maybe JD.string) Nothing + +external_html : JE.Value -> Html Msg +external_html html = + H.node "elm-html" + [ HA.property "html" html ] + [] + +format_score : Float -> Float -> String +format_score score marks = (String.fromFloat score)++"/"++(String.fromFloat marks) + +view model = case model.exam of + Nothing -> view_loading + Just exam -> + case model.view_mode of + ViewWholeExam -> view_exam model exam + + ViewQuestion n -> case get_question exam n of + Just question -> view_question question + Nothing -> H.text "That question doesn't exist" + + ViewPart n path -> case get_question exam n |> Maybe.andThen (\q -> get_part q path |> Maybe.map (pair q)) of + Just (question, part) -> view_part question part + Nothing -> H.text "That part doesn't exist" + + ViewPartAnswer n path -> case get_question exam n |> Maybe.andThen (\q -> get_part q path |> Maybe.map (pair q)) of + Just (question, part) -> view_part_answer_input question part + Nothing -> H.text <| "That part (q"++(String.fromInt n)++(path)++") doesn't exist" + +view_loading = H.text "It's loading" + +view_exam : Model -> Exam -> Html Msg +view_exam model exam = + let + exam_name = get_exam_name exam + questions = exam.questions + + in + H.div + [] + [ H.h1 [] [H.text <| exam_name] + {- + , H.ul + [] + (List.map (\msg -> H.li [] [H.text msg]) (List.take 5 model.messages)) + -} + , H.ul + [] + (List.map (\q -> H.li [] [view_question q]) questions) + ] + +view_question question = + H.div + [] + [ H.h2 [] [H.text question.name, H.text <| if question.isDirty then " (dirty)" else " (clean)"] + , H.p [] [H.text "Score: ", H.text <| String.fromFloat question.score] + , external_html question.statement + , H.ul + [] + (List.map (\p -> H.li [] [view_part question p]) question.parts) + ] + +view_part : Question -> Part -> Html Msg +view_part question part = + let + gaps = case part.gaps of + Gaps g -> g + in + H.div + [] + [ H.h3 [] [H.text part.name] + , H.p [] [H.text <| Debug.toString part.type_] + , H.p [] [H.text <| "Score: " ++ (format_score part.score part.marks)] + , external_html part.prompt + , view_part_answer_input question part + , H.ul + [] + (List.map (\f -> view_feedback_message f) part.marking_feedback) + ] + +view_part_answer_input question part = + let + msgs = + { store_answer = + pair "answer" + >> List.singleton + >> SendPartMessage question part "part_answer" + + , submit_answer = SendPartMessage question part "submit" [] + } + in + case part.type_ of + GapFillPart -> H.text "" + InformationOnlyPart -> H.text "" + _ -> + H.form + [ HE.onSubmit msgs.submit_answer + , HA.attribute "part" "answer-input-form" + , HE.on "focusout" (JD.succeed msgs.submit_answer) + ] + [ case part.type_ of + NumberEntryPart -> number_entry_input msgs + MathematicalExpressionPart -> mathematical_expression_input msgs + _ -> H.text "" + ] + +type alias AnswerMessages = + { store_answer : JE.Value -> Msg + , submit_answer : Msg + } + +type alias AnswerInput = AnswerMessages -> Html Msg + +number_entry_input : AnswerInput +number_entry_input msgs = + H.input + [ HE.onInput ( + JE.string + >> msgs.store_answer + ) + ] + [] + +mathematical_expression_input : AnswerInput +mathematical_expression_input msgs = + H.input + [ HE.onInput ( + JE.string + >> msgs.store_answer + ) + ] + [] + +view_feedback_message f = + let + feedback_icon = case f.credit_change of + Just "positive" -> H.text "+" + Just "negative" -> H.text "-" + _ -> H.text "" + in + H.li + [HA.class "feedback-message"] + [ feedback_icon + , external_html f.message + ] \ No newline at end of file diff --git a/style.css b/style.css new file mode 100644 index 0000000..848f306 --- /dev/null +++ b/style.css @@ -0,0 +1,3 @@ +section { + margin-bottom: 20vh; +} \ No newline at end of file