From 24fa9a2d779034aa096bb4f521f23d0e5df0fc4d Mon Sep 17 00:00:00 2001 From: Christian Lawson-Perfect Date: Sun, 9 Feb 2025 20:17:15 +0000 Subject: [PATCH] first commit --- .gitignore | 6 + .watchmakerc | 2 + Makefile | 11 + TODO | 1 + demo.mjs | 4 + elm.json | 25 + index.html | 45 ++ load-app.js | 136 ++++++ mathjax-config.js | 10 + numbas-elm-display.js | 1060 +++++++++++++++++++++++++++++++++++++++++ numbas-mathjax.js | 64 +++ numbas-part.css | 3 + show-error.mjs | 21 + src/App.elm | 479 +++++++++++++++++++ style.css | 3 + 15 files changed, 1870 insertions(+) create mode 100644 .gitignore create mode 100644 .watchmakerc create mode 100644 Makefile create mode 100644 TODO create mode 100644 demo.mjs create mode 100644 elm.json create mode 100644 index.html create mode 100644 load-app.js create mode 100644 mathjax-config.js create mode 100644 numbas-elm-display.js create mode 100644 numbas-mathjax.js create mode 100644 numbas-part.css create mode 100644 show-error.mjs create mode 100644 src/App.elm create mode 100644 style.css 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