Skip to content

Putting [[input]]s into TeX #1708

@anst-i

Description

@anst-i

It is currently not possible to use [[input]] boxes within TeX environments. It would be very useful to have this, though! (I tried looking for an open issue on that, but I didn't find any? If there is one, we can move over there.)

MathJax originally was not able to do this, but example scripts for MathJax 3 have been written.

I'm currently vibe coding my way through this. As a proof of concept, the following code works when added to "Appearance > Additional HTML > Within HEAD" by the Moodle admin for basic input boxes:

<style>
    /* Layout rules to make native Moodle inputs fit cleanly inside an equation */
    .mjx-math-input {
        display: inline-block !important;
        vertical-align: middle !important;
        margin: 0 4px !important;
        padding: 0.1rem 0.4rem !important;
        height: 2.2em !important;
        min-height: 2.2em !important;
        font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
    }
    .custom-validation-wrapper {
        margin-top: 5px;
        font-size: 0.9em;
        display: inline-block;
    }
</style>

<script>
(function() {
    var realMathJax = window.MathJax || {};

    Object.defineProperty(window, 'MathJax', {
        get: function() { return realMathJax; },
        set: function(newConfig) {
            if (newConfig && newConfig.startup) {
                
                newConfig.tex = newConfig.tex || {};
                newConfig.tex.packages = newConfig.tex.packages || {};
                if (Array.isArray(newConfig.tex.packages)) {
                    newConfig.tex.packages.push('custom-input-pkg');
                } else {
                    newConfig.tex.packages['[+]'] = newConfig.tex.packages['[+]'] || [];
                    newConfig.tex.packages['[+]'].push('custom-input-pkg');
                }

                var originalReady = newConfig.startup.ready || function() { MathJax.startup.defaultReady(); };
                
                newConfig.startup.ready = function() {
                    try {
                        const stackInputs = document.querySelectorAll('input[type="text"]');
                        
                        stackInputs.forEach(input => {
                            if (input.id && input.id.includes('ans')) {
                                
                                let target = input;
                                if (input.parentElement && input.parentElement.tagName === 'SPAN' && input.parentElement.className.includes('stack_input')) {
                                    target = input.parentElement;
                                }

                                let block = target.closest('p, div, td, li') || document.body;
                                let range = document.createRange();
                                range.setStart(block, 0);
                                range.setEndBefore(target);
                                let textBefore = range.toString();

                                let lastOpen = Math.max(textBefore.lastIndexOf('\\('), textBefore.lastIndexOf('\\['));
                                let lastClose = Math.max(textBefore.lastIndexOf('\\)'), textBefore.lastIndexOf('\\]'));

                                let isMath = lastOpen > lastClose;

                                if (isMath) {
                                    // THE FIX: Find the actual Moodle form and put the vault inside it!
                                    const parentForm = input.closest('form') || document.body;
                                    let vault = parentForm.querySelector('.stack-input-vault');
                                    if (!vault) {
                                        vault = document.createElement('div');
                                        vault.className = 'stack-input-vault';
                                        vault.style.display = 'none'; // Keep it safely hidden inside the form
                                        parentForm.appendChild(vault);
                                    }

                                    const id = input.id;
                                    const size = input.getAttribute('size') || '10';
                                    const width = size + 'ch'; 
                                    
                                    const macroText = ` \\input[${id}][${width}]{} `;
                                    const parent = target.parentNode;
                                    
                                    parent.insertBefore(document.createTextNode(macroText), target);
                                    vault.appendChild(input); 
                                    
                                    if (target !== input) target.remove(); 
                                    parent.normalize(); 
                                }
                            }
                        });
                    } catch(e) {
                        console.error("MathJax Pre-processor Error:", e);
                    }

                    try {
                        const {Configuration} = MathJax._.input.tex.Configuration;
                        const {CommandMap} = MathJax._.input.tex.SymbolMap;
                        
                        const InputMacro = function(parser, name) {
                            const id = parser.GetBrackets(name, '');
                            const w = parser.GetBrackets(name, '5em');
                            
                            const mtext = parser.create('node', 'mtext', [ parser.create('text', '') ], {
                                "class": "mjx-stack-input-placeholder",
                                "data-id": id,
                                "data-width": w
                            });
                            parser.Push(mtext);
                        };

                        new CommandMap('custom-input-pkg', { input: 'InputMacro' }, {InputMacro});
                        Configuration.create('custom-input-pkg', { handler: {macro: ['custom-input-pkg']} });
                    } catch(e) {
                        console.error("MathJax Package Error:", e);
                    }
                    
                    originalReady();
                };
            }
            realMathJax = newConfig;
        }
    });

    function swapPlaceholders() {
        const placeholders = document.querySelectorAll('.mjx-stack-input-placeholder:not(.processed)');
        
        placeholders.forEach(function(el) {
            el.classList.add('processed');
            
            const id = el.getAttribute('data-id') || '';
            const w = el.getAttribute('data-width') || '5em';
            
            // THE FIX: Prevent Moodle from throwing errors on empty macro passes
            if (!id) return; 
            
            const input = document.createElement('input');
            input.type = 'text';
            
            let realStackInput = document.getElementById(id); 

            if (realStackInput) {
                input.className = realStackInput.className;
                input.classList.add('mjx-math-input');
                input.style.width = w; 
                input.value = realStackInput.value;

                const syncAndTrigger = function(e) {
                    realStackInput.value = e.target.value;
                    realStackInput.dispatchEvent(new Event('input', { bubbles: true }));
                    realStackInput.dispatchEvent(new Event('change', { bubbles: true }));
                    
                    if (e.type === 'keyup') {
                        const keyEvent = new KeyboardEvent('keyup', {
                            bubbles: true, cancelable: true,
                            key: e.key, code: e.code, keyCode: e.keyCode
                        });
                        realStackInput.dispatchEvent(keyEvent);
                    }
                    if (typeof window.jQuery !== 'undefined') {
                        window.jQuery(realStackInput).trigger('change').trigger('keyup');
                    }
                };

                input.addEventListener('keyup', syncAndTrigger);
                input.addEventListener('input', syncAndTrigger);
                input.addEventListener('blur', function(e) {
                    realStackInput.value = e.target.value;
                    realStackInput.dispatchEvent(new Event('blur', { bubbles: true }));
                    if (typeof window.jQuery !== 'undefined') {
                        window.jQuery(realStackInput).trigger('blur');
                    }
                });
            }

            el.innerHTML = '';
            el.appendChild(input);
        });
    }

    const observer = new MutationObserver(swapPlaceholders);
    document.addEventListener("DOMContentLoaded", function() {
        observer.observe(document.body, { childList: true, subtree: true });
        swapPlaceholders(); 
    });

})();
</script>

Here's a sample question you can use:

questions-Dev Sandbox Course-Mathjax Input-20260317-1042.xml

There are currently three known issues to me with the code above:

  • The styling isn't exactly the same (but that's of minor importance to me right now)
  • It would be much nicer to have it shipped as part of the actual STACK code, instead of having to rely on a the Moodle admin adding this
  • This code only works for "basic" input boxes, in the sense that trying it within subscripts as in \(\displaystyle \int_{[[input:ans3]]}^{[[input:ans4]]} [[input:ans5]]\ \mathrm{d}x\) breaks the question.

I am working on both these issues and create a proper branch (off dev) when I'm ready for that.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions