From b1ac8a86c5e7a8e6efe309ad5227b2b1d3cd9d28 Mon Sep 17 00:00:00 2001 From: Remy Blank Date: Thu, 26 Dec 2024 00:18:17 +0100 Subject: [PATCH] Add quizz functionality. --- docs/demo/elements.md | 28 +++++++ tdoc/common/ext/__init__.py | 1 + tdoc/common/static/tdoc/core.js | 94 +++++++++++++++++++++++- tdoc/common/static/tdoc/early.js | 11 ++- tdoc/common/static/tdoc/exec-python.js | 2 +- tdoc/common/static/tdoc/quizz.js | 39 ++++++++++ tdoc/common/static/tdoc/styles.css.jinja | 53 ++++++++++++- 7 files changed, 219 insertions(+), 9 deletions(-) create mode 100644 tdoc/common/static/tdoc/quizz.js diff --git a/docs/demo/elements.md b/docs/demo/elements.md index 5334fab..82e6bd5 100644 --- a/docs/demo/elements.md +++ b/docs/demo/elements.md @@ -41,6 +41,34 @@ This solution is expanded by default. This solution has a different color, and no drop-down. ``` +## Quizzes + +The helpers in the +[`quizz.js`](https://github.com/t-doc-org/common/blob/main/tdoc/common/static/tdoc/quizz.js) +module enable the creation of quizzes as dynamic page elements. + + + +1. +2. +3. The input field of quizz questions without a prompt uses the whole line. + + ## IFrames The [YouTube](https://youtube.com/) video below is embedded with the diff --git a/tdoc/common/ext/__init__.py b/tdoc/common/ext/__init__.py index 82a4e9e..6b8bb56 100644 --- a/tdoc/common/ext/__init__.py +++ b/tdoc/common/ext/__init__.py @@ -59,6 +59,7 @@ def build_tag(app): def setup(app): + app.set_html_assets_policy('always') # Ensure MathJax is always available app.add_event('tdoc-html-page-config') app.add_config_value('license', '', 'html') diff --git a/tdoc/common/static/tdoc/core.js b/tdoc/common/static/tdoc/core.js index 5739802..a15167a 100644 --- a/tdoc/common/static/tdoc/core.js +++ b/tdoc/common/static/tdoc/core.js @@ -22,11 +22,18 @@ export function text(value) { return document.createTextNode(value); } +// Create an DocumentFragment node. +export function html(html) { + const el = document.createElement('template'); + el.innerHTML = html; + return el.content; +} + // Create an element node. export function element(html) { - const t = document.createElement('template'); - t.innerHTML = html.trim(); - return t.content.firstChild; + const el = document.createElement('template'); + el.innerHTML = html.trim(); + return el.content.firstChild; } // Return true iff the given element is within the root viewport. @@ -37,12 +44,93 @@ export function isVisible(el) { rect.right <= document.documentElement.clientWidth; } +// Return a containing inline math. The element must be typeset after +// being added to the DOM. +export function inlineMath(value) { + const el = element(''); + const [start, end] = MathJax.tex?.inlineMath ?? ['\\(', '\\)']; + el.appendChild(text(`${start}${value}${end}`)); + return el; +} + +// Return a
containing display math. The element must be typeset after +// being added to the DOM. +export function displayMath(value) { + // The formatting of the content corresponds to what spinx.ext.mathjax does. + const parts = []; + for (const p of value.split('\n\n')) { + if (p.trim()) parts.push(p); + } + const out = []; + if (parts.length > 1) out.push(' \\begin{align}\\begin{aligned}'); + for (const [i, p] of parts.entries()) { + const nl = p.includes('\\\\'); + if (nl) out.push('\\begin{split}'); + out.push(p); + if (nl) out.push('\\end{split}'); + if (i < parts.length - 1) out.push('\\\\'); + } + if (parts.length > 1) out.push('\\end{aligned}\\end{align} '); + const el = element('
'); + const [start, end] = MathJax.tex?.displayMath ?? ['\\[', '\\]']; + el.appendChild(text(`${start}${out.join('')}${end}`)); + return el; +} + +let typeset = globalThis.MathJax?.startup?.promise; +if (!typeset && globalThis.MathJax) { + if (!MathJax.startup) MathJax.startup = {}; + typeset = new Promise(resolve => { + MathJax.startup.pageReady = () => { + return MathJax.startup.defaultPageReady().then(resolve); + } + }); +} + +// Typeset the math contained in one or more elements. +export function typesetMath(...args) { + typeset = typeset.then(() => MathJax.typesetPromise(args)) + .catch(e => console.error(`Math typesetting failed: ${e}`)); + return typeset; +} + // Focus an element if it is visible and no other element has the focus. export function focusIfVisible(el) { const active = document.activeElement; if ((!active || active.tagName === 'BODY') && isVisible(el)) el.focus(); } +const cMinus = '-'.charCodeAt(0), cPlus = '+'.charCodeAt(0); +const c0 = '0'.charCodeAt(0), cA = 'A'.charCodeAt(0); + +// Convert a string to an integer. Returns undefined if the string is not a +// valid integer. +export function strToInt(s, radix = 10) { + if (!(2 <= radix && radix <= 36)) return; + s = s.toUpperCase(); + let i = 0, sign = 1, res = 0; + while (i < s.length) { + const c = s.charCodeAt(i++); + if (i === 1) { + if (c === cPlus) { + continue; + } else if (c === cMinus) { + sign = -1; + continue; + } + } + let d = 36; + if (c0 <= c && c < c0 + 10) { + d = c - c0; + } else if (cA <= c && c < cA + 26) { + d = 10 + c - cA; + } + if (d >= radix) return; + res = res * radix + d; + } + return sign * res; +} + // Convert binary data to base64. export function toBase64(data) { return new Promise((resolve, reject) => { diff --git a/tdoc/common/static/tdoc/early.js b/tdoc/common/static/tdoc/early.js index 1f64938..66e9387 100644 --- a/tdoc/common/static/tdoc/early.js +++ b/tdoc/common/static/tdoc/early.js @@ -15,6 +15,14 @@ }; } + const script = document.currentScript; + const staticUrl = new URL('..', script.src).toString(); + + // Import a module specified relative to the _static directory. + tdoc.import = async (module, options) => { + return await import(new URL(module, staticUrl), options); + } + // Set data-* attributes on the tag. Object.assign(document.documentElement.dataset, tdoc.html_data); @@ -30,8 +38,7 @@ // Set up the SharedArrayBuffer workaround as configured. const workers = navigator.serviceWorker; const enableSAB = tdoc['enable_sab']; - const script = document.currentScript; - const url = new URL(`../tdoc-worker.js?sab=${enableSAB}`, script.src) + const url = new URL(`../tdoc-worker.js?sab=${enableSAB}`, staticUrl) .toString(); if (enableSAB === 'no') { return (async () => { diff --git a/tdoc/common/static/tdoc/exec-python.js b/tdoc/common/static/tdoc/exec-python.js index ddb67c6..1ee527f 100644 --- a/tdoc/common/static/tdoc/exec-python.js +++ b/tdoc/common/static/tdoc/exec-python.js @@ -146,7 +146,7 @@ class PythonExecutor extends Executor { case 'line': { const input = div.appendChild(element(`\ `)); + autocorrect="off" spellcheck="false">`)); const btn = div.appendChild(element(`\ `)); btn.addEventListener('click', () => { resolve(input.value); }); diff --git a/tdoc/common/static/tdoc/quizz.js b/tdoc/common/static/tdoc/quizz.js new file mode 100644 index 0000000..4dfe1c7 --- /dev/null +++ b/tdoc/common/static/tdoc/quizz.js @@ -0,0 +1,39 @@ +// Copyright 2024 Remy Blank +// SPDX-License-Identifier: MIT + +import {element, focusIfVisible, text} from './core.js'; + +// Add a quizz question with a reply input field after the given node. Return +// the quizz question container element. +export function question(node, prompt, check) { + const div = element(`\ +
\ +
\ +
\ +
\ +
`); + if (prompt) { + if (typeof prompt === 'string') prompt = text(prompt); + div.querySelector('.prompt').appendChild(prompt); + } + const input = div.querySelector('input'); + const btn = div.querySelector('button'); + function checkResp(resp) { + input.parentNode.classList.remove('good', 'bad'); + input.parentNode.classList.add(check(resp) ? 'good' : 'bad'); + } + input.addEventListener('input', () => { + input.parentNode.classList.remove('good', 'bad'); + }); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.altKey && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + btn.click(); + } + }); + btn.addEventListener('click', () => { checkResp(input.value); }); + node.after(div); + focusIfVisible(input); + return div; +} diff --git a/tdoc/common/static/tdoc/styles.css.jinja b/tdoc/common/static/tdoc/styles.css.jinja index b30fa54..0722634 100644 --- a/tdoc/common/static/tdoc/styles.css.jinja +++ b/tdoc/common/static/tdoc/styles.css.jinja @@ -130,8 +130,8 @@ button[class*=" tdoc-"] { background-color: var(--pst-color-surface); transition: background-color 0.3s; } -button[class^=tdoc-]:hover, -button[class*=" tdoc-"]:hover { +button[class^=tdoc-]:hover:not([disabled]), +button[class*=" tdoc-"]:hover:not([disabled]) { background-color: var(--pst-color-border); } button[class^=tdoc-][class*=" fa-"], @@ -150,6 +150,54 @@ input[class*=" tdoc-"] { padding-inline: 2px; } +/* Quizz questions */ +div.tdoc-quizz { + display: flex; + align-items: baseline; + margin: 0.3rem 0; + column-gap: 0.3rem; +} +div.tdoc-quizz > .prompt { + flex: 1 1 auto; + white-space: pre-wrap; + padding-right: 0.4rem; +} +div.tdoc-quizz > .prompt:empty { + display: none; +} +div.tdoc-quizz > .input { + flex: 0 0 40%; + position: relative; + display: flex; + align-items: center; + column-gap: 0.3rem; +} +div.tdoc-quizz > .prompt:empty + .input { + flex: 0 0 100%; +} +div.tdoc-quizz > .input > input { + width: 100%; + border: 1px solid var(--pst-color-border); + padding-block: 1px; + padding-inline-start: 2px; + padding-inline-end: 2rem; +} +div.tdoc-quizz > .input::before, +div.tdoc-quizz > .input::before { + position: absolute; + right: 2.4rem; + font: var(--fa-font-solid); + font-size: 1.5rem; +} +div.tdoc-quizz > .input.bad::before { + content: '\f00d'; + color: red; +} +div.tdoc-quizz > .input.good::before { + content: '\f00c'; + color: green; +} + /* Auto-sizing text area */ .tdoc-autosize { display: grid; @@ -214,7 +262,6 @@ div.tdoc-exec-output pre span.err { div.tdoc-exec-output div.tdoc-input { display: flex; flex-direction: row; - align-content: stretch; align-items: baseline; margin: 0; padding: 0.3rem;