Skip to content

Commit

Permalink
Add quizz functionality.
Browse files Browse the repository at this point in the history
  • Loading branch information
rblank committed Dec 25, 2024
1 parent 434de97 commit b1ac8a8
Show file tree
Hide file tree
Showing 7 changed files with 219 additions and 9 deletions.
28 changes: 28 additions & 0 deletions docs/demo/elements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<script>
async function question(text, want) {
const node = document.currentScript;
const quizz = await tdoc.import('tdoc/quizz.js');
quizz.question(node, text, resp => resp === want);
}
</script>

1. <script>
const value = Math.floor(256 * Math.random());
question(`Convert 0b${value.toString(2).padStart(8, '0')} to decimal.`,
value.toString());
</script>
2. <script>
question(`\
What is the answer to the ultimate question of life, the universe, and \
everything? Explain your reasoning in full detail, provide references, and \
indicate plausible alternatives.`, '42');
</script>
3. The input field of quizz questions without a prompt uses the whole line.
<script>question(undefined, "I see");</script>

## IFrames

The [YouTube](https://youtube.com/) video below is embedded with the
Expand Down
1 change: 1 addition & 0 deletions tdoc/common/ext/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
94 changes: 91 additions & 3 deletions tdoc/common/static/tdoc/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -37,12 +44,93 @@ export function isVisible(el) {
rect.right <= document.documentElement.clientWidth;
}

// Return a <span> containing inline math. The element must be typeset after
// being added to the DOM.
export function inlineMath(value) {
const el = element('<span class="math notranslate nohighlight"></span>');
const [start, end] = MathJax.tex?.inlineMath ?? ['\\(', '\\)'];
el.appendChild(text(`${start}${value}${end}`));
return el;
}

// Return a <div> 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('<div class="math notranslate nohighlight"></div>');
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) => {
Expand Down
11 changes: 9 additions & 2 deletions tdoc/common/static/tdoc/early.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <html> tag.
Object.assign(document.documentElement.dataset, tdoc.html_data);

Expand All @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion tdoc/common/static/tdoc/exec-python.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ class PythonExecutor extends Executor {
case 'line': {
const input = div.appendChild(element(`\
<input class="input" autocapitalize="off" autocomplete="off"\
autocorrect="off" spellcheck="false"></input>`));
autocorrect="off" spellcheck="false">`));
const btn = div.appendChild(element(`\
<button class="tdoc-send" title="Send input (Enter)">Send</button>`));
btn.addEventListener('click', () => { resolve(input.value); });
Expand Down
39 changes: 39 additions & 0 deletions tdoc/common/static/tdoc/quizz.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright 2024 Remy Blank <[email protected]>
// 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(`\
<div class="tdoc-quizz">\
<div class="prompt"></div>\
<div class="input">\
<input autocapitalize="off" autocomplete="off" autocorrect="off"\
spellcheck="false"><button class="tdoc-check fa-check"></button></div>\
</div>`);
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;
}
53 changes: 50 additions & 3 deletions tdoc/common/static/tdoc/styles.css.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -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-"],
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down

0 comments on commit b1ac8a8

Please sign in to comment.