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
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions