Lurch web app user interface

source

editor.js


/**
 * This file installs a global Lurch namespace in the browser window.  Clients
 * can use that to install the Lurch app into their page at a chosen location.
 */

// Import the FileSystem module, and then several other modules that are
// imported only because importing them registers them as subclasses of
// FileSystem, even though we don't use them directly here.
import FileSystem from './file-system.js'
import { BrowserFileSystem } from './browser-file-system.js'
import { OfflineFileSystem } from './offline-file-system.js'
import { WebFileSystem } from './web-file-system.js'
import { DropboxFileSystem } from './dropbox-file-system.js'
import { MyCourseFileSystem } from './my-course-file-system.js'

import { loadScript, makeAbsoluteURL, isEmbedded } from './utilities.js'
import { loadFromQueryString } from './load-from-url.js'
import { appSettings } from './settings-install.js'
import { LurchDocument } from './lurch-document.js'
import Settings from './settings-install.js'
import Headers from './header-editor.js'
import DocSettings from './document-settings.js'
import Atoms from './atoms.js'
import Expressions from './expressions.js'
import ExpositoryMath from './expository-math.js'
import Dependencies from './dependencies.js'
import Shells from './shells.js'
import Validation from './validation.js'
import AutoCompleter from './auto-completer.js'
import Embedding from './embed-listener.js'
import Export from './export.js'

import { stylesheet as MathLiveCSS } from './math-live.js'

// TinyMCE's CDN URL, from which we will load it
const TinyMCEURL = 'https://cdnjs.cloudflare.com/ajax/libs/tinymce/6.6.2/tinymce.min.js'

/**
 * This namespace is installed globally when importing `editor.js`.  It allows
 * the client to customize the behavior of the Lurch app, then install that app
 * wherever in their page they want it installed.
 * 
 * To customize the default settings for the application and any documents it
 * loads, see {@link Lurch.setAppDefaults setAppDefaults()} and
 * {@link Lurch.setDocumentDefaults setDocumentDefaults()}.  To create
 * an instance of the Lurch app on your page, see
 * {@link Lurch.createApp createApp()}.
 * 
 * @namespace Lurch
 */
window.Lurch = {
    
    /**
     * This is the main function that is to be used by clients.  It creates an
     * instance of the Lurch app in any element on the page.  Typically, one
     * calls this on a textarea to be used as the editor, or on a DIV into which
     * you want this function to automatically create a new texteditor to use
     * as the base for the app.
     * 
     * It returns a Promise that resolves when TinyMCE (the underlying editor
     * technology on which Lurch is built) has completed its setup phase (though
     * the editor instance may not yet have had its `init` event called).  The
     * resolve call will be passed the new TinyMCE editor instance.
     * 
     * The `options` object can have any subset of the following fields:
     * 
     *  - `options.menuData` will be used to override default menus.  The format
     *    is the same as it is for {@link https://www.tiny.cloud/docs/tinymce/latest/menus-configuration-options/#menu
     *    TinyMCE menu specifications}.
     *  - `options.menuItems` will be used to override the attributes of the
     *    menu items that Lurch adds to TinyMCE.  (This does not apply to
     *    built-in TinyMCE menu items, like headings and undo and so forth, only
     *    to Lurch menu items like inserting math or environments, showing
     *    feedback, etc.)  The format is a mapping from internal menu item names
     *    (e.g., `"expositorymath"`) to an object containing attributes to
     *    override.  For example, to change the text of the `"expositorymath"`
     *    menu item from "Expository math" to "LaTeX", use the code
     *    `"menuItems": { "expositorymath": { text: "LaTeX" } }`.  All other
     *    menu item attributes are also available, including `"text"`, `"icon"`,
     *    `"shortcut"`, and `"tooltip"`.  Even the menu item's action can be
     *    overridden in this way by specifying `"onAction": myFunction`, except
     *    when loading these options from a configuration file in JSON format,
     *    because that format does not support functions.
     *  - `options.toolbarData` will be used to override default toolbars.  The
     *    format is the same as it is for {@link https://www.tiny.cloud/docs/tinymce/latest/toolbar-configuration-options/#toolbar
     *    TinyMCE toolbar specifications}.
     *  - `options.editor` will be used to override anything passed to TinyMCE's
     *    `init()` call.  This is inherently stronger than the previous two
     *    combined, because menu and toolbar data is part of what is passed to
     *    the `init()` call, but it can be cumbersome to override the entire
     *    menu or toolbar setup, and thus the previous two options are available
     *    for overriding just pieces of them.  E.g., you can provide `menuData`
     *    as `{ 'file' : { ... } }` and it will affect only the file menu,
     *    because it is incorporated into the defaults using `Object.assign()`.
     *  - `options.preventLeaving` enables or disables the feature that prompts
     *    the user to confirm before leaving the page, so that they do not lose
     *    their work by accidentally reloading the page or closing a tab.  If
     *    this value is not set, the default is `true` for the main app and
     *    `false` for embedded version of the app (e.g., in our documentation
     *    site, where users typically are not creating documents they care to
     *    save).
     *  - `options.fileOpenTabs` can be used to reorder or subset the list of
     *    tabs in the File > Open dialog box, which defaults to
     *    `[ 'From in-browser storage', 'From your computer', 'From the web',
     *    'From Dropbox', 'From my course' ]`.  Each of these corresponds to a
     *    subclass of the {@link FileSystem} class, and those are (respectively)
     *    {@link BrowserFileSystem}, {@link OfflineFileSystem},
     *    {@link WebFileSystem}, and {@link MyCourseFileSystem}.
     *  - `options.fileSaveTabs` can be used to reorder or subset the list of
     *    tabs in the File > Save dialog box, which defaults to
     *    `[ 'To in-browser storage', 'To your computer', 'To Dropbox' ]`.  See
     *    above for documentation on the relevant {@link FileSystem} subclasses.
     *  - `options.fileDeleteTabs` can be used to reorder or subset the list of
     *    tabs in the File > Delete dialog box, which defaults to
     *    `[ 'In in-browser storage', 'In Dropbox' ]`.  See above for
     *    documentation on the relevant {@link FileSystem} subclasses.
     *  - `options.helpPages` can be an array of objects of the form
     *    `{ title : '...', url : '...' }`.  These will be displayed in the help
     *    menu (which is omitted if no such pages are provided) in the order
     *    they appear in this option.  Clicking any one of them just opens the
     *    URL in a new window.  This allows each Lurch installation to have its
     *    own custom set of help pages for students or other users.
     *  - `options.autoSaveEnabled` enables or disables the feature of
     *    auto-saving the user's work into the browser's local storage every few
     *    seconds.  This is `true` (enabled) by default for the main app, but
     *    false by default for embedded copies of the app.  You can use this
     *    setting to change that default.
     *  - `options.appRoot` can be a relative path from the `index.html` file to
     *    the root of the repository in which `editor.js` is located.  If you
     *    are using the `index.html` provided in the Lurch repository, then you
     *    do not need to provide this path, and it will default to `'.'`, which
     *    is correct.  If your HTML page is in a different folder than this
     *    repository, you will need to provide the path from the HTML page to
     *    the repository.  This is essential so that the app can find CSS and JS
     *    files in the repository to load programmatically as needed.
     *  - `options.appDefaults` can be a dictionary that overrides the default
     *    application settings.  Doing so may not affect the experience of a
     *    user who has already customized their own application settings,
     *    because this set of key-value pairs supplies only the *default*
     *    settings.  The user's chosen settings naturally override the defaults.
     *    To see which keys and values are available, see
     *    {@link SettingsInstaller the settings installer module}, and view the
     *    source code for the `appSettings` object.
     *  - `options.documentDefaults` can be a dictionary that overrides the
     *    default document settings.  Doing so may not affect the experience of
     *    a user who loads a document that includes its own settings, because
     *    this set of key-value pairs supplies only the *default* settings.  The
     *    current document's settings naturally override the defaults.
     *    To see which keys and values are available, see
     *    {@link LurchDocument.settingsMetadata the document settings metadata}
     *    in the {@link LurchDocument} class.
     *  - `options.myCourse` can be a hierarchical structure that will be used
     *    to populate the contents of the "My course" file system.  See the
     *    documentation of the {@link MyCourseFileSystem} class for details.
     *  - `options.documentStylesheets` can be an array of URLs that will be
     *    appended to the TinyMCE editor's list of stylesheets for the document
     *    (named `content_css` in TinyMCE).
     * 
     * The `options` object is stored as an `appOptions` member in the TinyMCE
     * editor instance once it is created, so that any part of the app can refer
     * back to these options later.
     * 
     * @param {HTMLElement} element - the element into which to install the app
     * @param {Object?} options - the options to use for the new app
     * @returns {Promise} a promise that resolves as documented above
     * @function
     * @memberof Lurch
     */
    createApp : ( element, options = { } ) => {

        // Fill in defaults for the options object.
        options = Object.assign( {
            appDefaults : { },
            documentDefaults : { },
            menuData : { },
            helpPages : [ ],
            appRoot : '.',
            editor : { },
            preventLeaving : !isEmbedded(),
            autoSaveEnabled : !isEmbedded(),
            toolbarData : 'undo redo | '
                + 'styles bold italic | '
                //   + 'link unlink | ' // reduce toolbar clutter
                + 'alignleft aligncenter alignright outdent indent | '
                + 'numlist bullist'
        }, options )
        // Handle menuData separately, because we support supplying just a part
        // of the menuData object and having the rest filled in by defaults:
        const buildMenu = ( title, ...list ) => {
            return { title, items : list.join( ' | ' ) }
        }
        const menuData = Object.assign( {
            file : buildMenu( 'File',
                'newlurchdocument opendocument savedocument savedocumentas deletesaved',
                'embeddocument exportlatex',
                'print'
            ),
            edit : buildMenu( 'Edit',
                'undo redo',
                'cut copy paste pastetext',
                'selectall',
                'link unlink openlink',
                'searchreplace',
                'listprops',
                'preferences'
            ),
            insert : buildMenu( 'Insert',
                'link emoticons hr',
                'insertdatetime',
                'expression expositorymath',
                'environment paragraphabove paragraphbelow'
            ),
            format : buildMenu( 'Format',
                'bold italic underline strikethrough superscript subscript',
                'styles blocks fontfamily fontsize align lineheight',
                'forecolor backcolor',
                'language',
                'removeformat'
            ),
            document : buildMenu( 'Document',
                'viewdependencyurls',
                'validate clearvalidation',
                'docsettings togglemeaning'
            ),
            help : buildMenu( 'Help', 'aboutlurch' )
        }, options.menuData )

        // If the options object specifies default app settings, apply them:
        Object.keys( options.appDefaults ).forEach( key => {
            const settingMetadata = appSettings.metadata.metadataFor( key )
            if ( !settingMetadata )
                console.log( 'No such setting:', key )
            else
                settingMetadata.defaultValue = options.appDefaults[key]
        } )
        // And then have the settings recompute its cached default values:
        appSettings.defaults = appSettings.metadata.defaultSettings()
        // Do the same for default document settings:
        Object.keys( options.documentDefaults ).forEach( key => {
            const settingMetadata = LurchDocument.settingsMetadata.metadataFor( key )
            if ( !settingMetadata )
                console.log( 'No such setting:', key )
            else
                settingMetadata.defaultValue = options.documentDefaults[key]
        } )

        // Ensure the element is/has a textarea, so we can install TinyMCE there
        if ( element.tagName !== 'TEXTAREA' ) {
            element.insertBefore( document.createElement( 'textarea' ),
                element.firstChild )
            element = element.firstChild
            element.setAttribute( 'id', 'editor' )
        }

        // If developer mode is enabled in settings, create the Developer menu
        if ( appSettings.get( 'developer mode on' ) === true )
            menuData.developer = buildMenu( 'Instructor',
               'editdependencyurls',
               'viewdocumentcode redpen'
            )

        // Add any help pages from the options object to a new help menu.
        // Further below, during editor initialization, we will install menu
        // items with these names, associated with these help pages.
        // (Each will be an object of the form {title,url}.)
        options.helpPages.forEach( ( _, index ) => {
            if ( !menuData.help )
                menuData.help = buildMenu( 'Help', `helpfile${index+1}` )
            else
                menuData.help.items += ' ' + `helpfile${index+1}`
        } )

        // Load TinyMCE from its CDN...
        return new Promise( ( resolve, _ ) => loadScript( TinyMCEURL ).then( () => {
            // ...then set up the editor in the textarea from above,
            // again overriding any of our default options with those specified
            // in the options object passed to createApp(), if any.
            const tinymceSetupOptions = Object.assign( {
                selector : '#editor',
                content_css : [
                    'document',
                    `${options.appRoot}/syntax-theme.css`,
                    MathLiveCSS,
                    ...( options.documentStylesheets || [ ] )
                ],
                visual_table_class : 'lurch-borderless-table',
                height : "100%",
                promotion : false, // disable premium features advertisement
                toolbar : options.toolbarData,
                menubar : 'file edit insert format document developer help',
                menu : menuData,
                browser_spellcheck: true,
                contextmenu : 'atoms',
                plugins : 'lists link',
                link_context_toolbar: true,
                statusbar : false,
                setup : editor => {
                    // Override the menu item and toolbar creation functions
                    // with my own copy, which allows for name customization
                    const origAddMenuItem = editor.ui.registry.addMenuItem
                    editor.ui.registry.addMenuItem = ( name, item ) =>
                        origAddMenuItem( name,
                            Object.assign( item, options.menuItems?.[name] || { } ) )

                    // Save the options object for any part of the app to reference:
                    editor.appOptions = options

                    // As soon as the editor is ready...
                    editor.on( 'init', () => {
                        // Ensure it's not in front of any later Google Drive dialogs:
                        document.querySelector( '.tox-tinymce' ).style.zIndex = 500
                        // And ensure it has a lurchDocument property:
                        new LurchDocument( editor )
                    } )

                    // Install all tools the editor always needs:
                    Settings.install( editor )
                    Atoms.install( editor )
                    Expressions.install( editor )
                    ExpositoryMath.install( editor )
                    Shells.install( editor )
                    Dependencies.install( editor )
                    Validation.install( editor )
                    AutoCompleter.install( editor )
                    Export.install( editor )
                    FileSystem.install( editor )
                    Headers.install( editor )
                    DocSettings.install( editor )
                    Embedding.install( editor )
                    editor.on( 'init', () => loadFromQueryString( editor ) )

                    // Install any help pages specified in the options object
                    options.helpPages.forEach( ( page, index ) => {
                        editor.ui.registry.addMenuItem( `helpfile${index+1}`, {
                            text : page.title,
                            // icon : 'help',
                            onAction : () =>
                                window.open( makeAbsoluteURL( page.url ), '_blank' )
                        } )
                    } )            
                    // Add About Lurch menu item
                    editor.ui.registry.addMenuItem( 'aboutlurch', {
                        text : 'About Lurch',
                        // icon : 'help',
                        tooltip : 'About Lurch',
                        onAction : () => window.open(
                            'https://lurchmath.github.io/site/about/', '_blank' )
                    } )

                    // Add red pen menu item
                    editor.ui.registry.addMenuItem( 'redpen', {
                        text : 'Grading pen',
                        tooltip : 'Enable grading pen style',
                        shortcut : 'meta+shift+G',
                        icon : 'highlight-bg-color',
                        onAction : () => {
                            editor.execCommand( 'Italic' )
                            editor.execCommand( 'ForeColor', false, '#DA1D0C' )
                            editor.execCommand( 'FontName', false, 'Georgia' )
                            editor.execCommand( 'FontSize', false, '1.1rem' )
                        }
                    } )

                    // Create keyboard shortcuts for all menu items
                    const menuItems = editor.ui.registry.getAll().menuItems
                    for ( let itemName in menuItems ) {
                        const item = menuItems[itemName]
                        if ( item.hasOwnProperty( 'shortcut' ) ) {
                            const shortcut = item.shortcut
                                .replace( /enter/i, '13' )
                                .replace( /space/i, '32' )
                            editor.addShortcut( shortcut, item.text,
                                () => item.onAction() )
                        }
                    }

                    // Handle tab key in a way more like what users will expect
                    editor.on( 'keydown', event => {
                        if ( event.keyCode == 9 ) {
                            if ( event.shiftKey ) {
                                editor.execCommand( 'outdent' )
                            } else {
                                editor.execCommand( 'indent' )
                            }
                            event.preventDefault()
                            event.stopPropagation()
                            return false
                        }
                    } )

                    // Do not let the user leave the page accidentally, only on
                    // purpose (after confirming via dialog).  See docs above
                    // for the default value of this feature.
                    if ( options.preventLeaving )
                        window.addEventListener( 'beforeunload', event => {
                            // Note: The following code is NOT the same as just
                            // assigning isDirty() to the returnValue.
                            if ( editor.isDirty() ) event.returnValue = true
                        } )

                    // resolve the outer promise, to say that we finished
                    // TinyMCE setup
                    resolve( editor )

                    // Put the cursor in the editor at startup
                    editor.once( 'PostRender', () => editor.focus() )
                }
            }, options.editor )
            tinymce.init( tinymceSetupOptions )
        } ) )
    }

}