Lurch web app user interface

source

expository-math.js


/**
 * Expository math is content in a Lurch document that is meant to be read for
 * human comprehension but not as part of the logical content of a proof.  For
 * instance, a document author might want to add some side comments that include
 * variables, equations, or other expressions, typeset with LaTeX, but that are
 * not intended for the system to grade, interpret, or obey.
 * 
 * For example, if an instructor is writing the rules for a logical system, they
 * might want to show how NOT to use the rules, or how NOT to write expressions,
 * and they don't want their document to contain validation errors, as if there
 * were something wrong with it, when it's really just showing by example what
 * to avoid.
 * 
 * We therefore provide a new type of {@link Atom} that functions very similar
 * to the {@link Expression} type, with two exceptions.  First, the notation
 * used is LaTeX, not Lurch notation.  Second, the content is not interpreted by
 * the system for use in validation; more precisely, it is completely ignored
 * during the conversion of a document to LCs, which is then used in validation.
 * 
 * @module ExpositoryMathAtoms
 */

import { Atom } from './atoms.js'
import { represent } from './notation.js'
import { appSettings } from './settings-install.js'
import { Dialog, TextInputItem, LongTextInputItem } from './dialog.js'
import { MathItem } from './math-live.js'
import { escapeLatex } from './utilities.js'

/**
 * An Atom that represents a piece of mathematical notation used only for
 * exposition
 */
export class ExpositoryMath extends Atom {

    static subclassName = Atom.registerSubclass( 'expositorymath', ExpositoryMath )

    /**
     * Shows a dialog for editing an expository math atom, but it may style the
     * dialog differently, depending on whether the user has chosen beginner,
     * intermediate, or advanced mode for the expository math editor.
     * 
     * In beginner mode, the dialog contains only a MathLive editor, which the
     * user uses to create their expository math content.
     * 
     * In any other mode, the dialog has two portions, a text input that accepts
     * LaTeX input and a MathLive editor that allows editing of WYSIWYG math
     * content.  These two stay in sync, in that if the user edits either one,
     * the other is updated automatically.
     * 
     * Advanced mode differs from intermediate mode in two ways.  First, the
     * header, footer, and labels in the dialog are removed to style it like
     * the advanced mode editor for {@link Expression} atoms.  Second, the
     * MathLive editor is read-only, also to imitate the advanced mode behavior
     * of the dialog for {@link Expression} atoms.
     * 
     * @returns {Promise} same convention as specified in
     *   {@link module:Atoms.Atom#edit edit() for Atoms}
     */
    edit () {
        const mode = appSettings.get( 'expository math editor type' )
        const latex = this.getMetadata( 'latex' )
        // set up dialog contents
        const dialog = new Dialog( 'Edit expository math', this.editor )
        dialog.hideHeader = dialog.hideFooter = mode == 'Advanced'
        const latexInput = mode == 'Advanced' 
            // use an expandable textarea for Advanced mode
            ? new LongTextInputItem( 'latex', '', '' )
            : new TextInputItem( 'latex', 'LaTeX notation', '' )
        dialog.addItem( latexInput )
        const textSelector = mode == 'Advanced' 
            ? 'textarea[type="text"]'
            : 'input[type="text"]'
        const mathLivePreview = new MathItem( 'preview',
            mode == 'Advanced' ? '' : 'Math editor' )
        mathLivePreview.finishSetup = () => {
            if ( mode == 'Advanced' ) {
                mathLivePreview.mathLiveEditor.readOnly = true
                mathLivePreview.mathLiveEditor.style.border = 0
                mathLivePreview.mathLiveEditor.style.padding = '0.5rem 0 0 0.5rem'
            }
            mathLivePreview.setValue( latex )
        }
        dialog.addItem( mathLivePreview )
        // initialize dialog with data from the atom
        dialog.setInitialData( { latex } )
        dialog.setDefaultFocus( 'latex' )
        // utility: is the current latex empty?
        const empty = () => dialog.get( 'latex' ).trim() == ''
        // if they edit the Lurch notation or latex, keep them in sync
        dialog.onChange = ( _, component ) => {
            if ( component.name == 'latex' )
                mathLivePreview.setValue( dialog.get( 'latex' ) )
            if ( component.name == 'preview' )
                dialog.querySelector( textSelector ).value =
                    mathLivePreview.mathLiveEditor.value
            dialog.dialog.setEnabled( 'OK', !empty() )
        }
        // Show it and if they accept any changes, apply them to the atom.
        const result = dialog.show().then( userHitOK => {
            if ( !userHitOK || empty() ) return false
            this.setMetadata( 'latex', dialog.get( 'latex' ) )
            this.update()
            return true
        } )
        dialog.dialog.setEnabled( 'OK', !empty() )
        // prevent enter to confirm if the input is empty
        const latexInputElement = dialog.querySelector( textSelector )
        latexInputElement.addEventListener( 'keydown', event => {
            if ( event.key == 'Enter' && empty() ) {
                event.preventDefault()
                event.stopPropagation()
                return false
            }
        } )
        if (mode === 'Advanced') {
            // set the initial height based on the number of current lines
            // of text in the initial value, plus wordwrap at 45 chars
            const computeHeight = (s) => 10+24*Math.max(1,
                s.split( '\n' ).reduce( (total,line) => 
                    { return total+Math.max(1,Math.ceil(line.length/45)) },0)) 
            
            latexInputElement.style.height = computeHeight(latex)+'px'

            latexInputElement.addEventListener('input', () => {
              latexInputElement.style.height = 
                  computeHeight(latexInputElement.value)+'px'
            })

            // listen for the Enter and Shift+Enter keys        
            latexInputElement.addEventListener( 'keydown', event => {
                if ( event.key == 'Enter' ) {
                    if ( !event.shiftKey ) {
                        // Plain enter submits whether or not it is valid
                        // and lets the renderer's error system handle it
                        dialog.querySelector( 'button[title="OK"]' ).click()
                    }
                }
            } )
            // add the css class
            latexInputElement.classList.add( 'advancedTextArea' )
            // give it focus, but if it ever loses focus, close the dialog
            latexInputElement.focus()
            latexInputElement.addEventListener( 'blur', () =>
                setTimeout( () => dialog.close() ) )          
        }
        // hide latex input for beginner mode
        latexInputElement.parentNode.style.display = mode == 'Beginner' ? 'none' : ''
        return result
    }

    /**
     * Render the LaTeX from the dialog as HTML and place that HTML into the
     * body of the atom.  No check is done to be sure that the LaTeX is valid;
     * the user can type invalid LaTeX and get erroneous typeset results.
     */
    update () {
        const latex = this.getMetadata( 'latex' )
        this.fillChild( 'body', `${represent( latex, 'latex' )}` )
    }

    /**
     * All atoms must be able to represent themselves in LaTeX form, so that the
     * document (or a portion of it) can be exporeted for use in a LaTeX editor,
     * such as Overleaf.  This function overrides the default implementation
     * with a representation suitable to expository math atoms, which is just to
     * enclose their LaTeX content in dollar signs.
     * 
     * @returns {string} LaTeX representation of an expository math atom
     */
    toLatex () { return `$${this.getMetadata( 'latex' )}$` }
    
    /**
     * When embedding a copy of the Lurch app in a larger page, users will want
     * to write simple HTML describing a Lurch document, then have a script
     * create a copy of the Lurch app and put that document into it.  We allow
     * for representing expository math using `<latex>...</latex>` elements,
     * which contain LaTeX notation.  This function can convert any expository
     * math atom into the corresponding `latex` element, as a string.
     * 
     * @returns {string} the representation of the atom as a `lurch` element
     */
    toEmbed () { return `<latex>${this.getMetadata( 'latex' )}</latex>` }

}

/**
 * Install into a TinyMCE editor instance a new menu item:  "Expository math,"
 * intended for the Insert menu.  It creates an inline atom that can be inserted
 * into the user's document, then initiates editing on it, so that the user can
 * customize it and then confirm or cancel the insertion of it.
 * The inline atom has no mathematical meaning and is ignored during validation,
 * but can be used for expository purposes, hence the name.
 * 
 * We also install a keyboard handler for the `$` key, in case the user has
 * enabled the setting that interprets that key as initiating insertion of an
 * expository math expression.
 * 
 * @param {tinymce.Editor} editor the TinyMCE editor instance into which the new
 *   menu item should be installed
 * @function
 */
export const install = editor => {
    const insertExpositoryMath = () => {
        const atom = Atom.newInline( editor, '', {
            type : 'expositorymath',
            latex : ''
        } )
        atom.update()
        atom.editThenInsert()
    }
    editor.ui.registry.addIcon( 'tex', `
        <svg width="24" height="24" viewBox="0 0 24 24">
            <text x="0" y="18" font-size="8" font-family="sans-serif">$$</text>
        </svg>` )
    editor.ui.registry.addMenuItem( 'expositorymath', {
        text : 'Expository math',
        tooltip : 'Insert expository math',
        icon : 'tex',
        shortcut : '$',
        onAction : insertExpositoryMath
    } )
    // Install that function as what happens when you type a dollar sign,
    // as in LaTeX.  (Yes, this means that you can't type a dollar sign in Lurch.
    // We will later make that into a configurable option.)
    editor.on( 'init', () => {
        editor.dom.doc.body.addEventListener( 'keypress', event => {
            if ( event.key == '$' && appSettings.get( 'dollar sign shortcut' ) ) {
                event.preventDefault()
                event.stopPropagation()
                insertExpositoryMath()
            }
        } )
    } )
}

export default { install }