Lurch Web User Interface

source

dependencies.js


/**
 * This file installs one tool into the user interface, a menu item for
 * inserting a dependecy-type atom into the document.  A user who edits such an
 * atom can load any document into that dependency from any source supported by
 * the {@link Dialog.loadFile Dialog.loadFile()} function.
 * 
 * Such an atom will have two important properties:
 * 
 *  * Its `"description"` metadata entry will contain whatever text the user
 *    wants to use to make the dependency easy to identify when scrolling
 *    through a document, so the reader doesn't need to open it up to know
 *    what's inside.  This is a simple piece of metadata, not HTML-type
 *    metadata; the difference between the two is documented
 *    {@link module:Atoms.Atom#getHTMLMetadata here}.
 *  * Its `"content"` HTML metadata entry will contain the full content of the
 *    dependency that was loaded, or it will be absent if the atom has not yet
 *    been configured by the user.  This is a piece of HTML metadata, not simple
 *    metadata, because it will typically be large; the difference between the
 *    two is documented {@link module:Atoms.Atom#getHTMLMetadata here}.
 * 
 * @module Dependencies
 */

import { Atom, className } from './atoms.js'
import { openFileInNewWindow } from './load-from-url.js'
import { simpleHTMLTable, escapeHTML, escapeLatex } from './utilities.js'
import {
    Dialog, ButtonItem, TextInputItem, HTMLItem, CheckBoxItem
} from './dialog.js'
import { loadFromURL } from './load-from-url.js'

/**
 * Install into a TinyMCE editor instance a new menu item: Import dependency,
 * intended for the Document menu.  It adds a dependency atom (with no content
 * or description) to the user's document, and if the user clicks it, they can
 * then edit both in a popup dialog.
 * 
 * This assumes that the TinyMCE initialization code includes the "dependency"
 * item on one of the menus.
 * 
 * @param {tinymce.Editor} editor the TinyMCE editor instance into which the new
 *   menu item should be installed
 * @function
 */
export const install = editor => {
    editor.ui.registry.addMenuItem( 'dependency', {
        icon : 'duplicate-row',
        text : 'Import dependency',
        tooltip : 'Insert block for importing a dependency',
        onAction : () => {
            const atom = Atom.newBlock( editor, '', {
                type : 'dependency',
                description : 'none',
                autoRefresh : false
            } )
            atom.update()
            atom.editThenInsert()
        }
    } )
    editor.ui.registry.addMenuItem( 'refreshdependencies', {
        icon : 'reload',
        text : 'Refresh dependencies',
        tooltip : 'Refresh all dependencies whose source is a URL',
        onAction : () => {
            editor.setProgressState( true )
            Promise.all( [
                Dependency.refreshAllIn( editor.lurchMetadata ),
                Dependency.refreshAllIn( editor.getBody() )
            ] ).then( () => {
                editor.setProgressState( false )
                Dialog.notify( editor, 'success', 'Refreshed all dependencies.' )
            } ).catch( error => {
                editor.setProgressState( false )
                Dialog.notify( editor, 'error', error )
            } )
        }
    } )
}

// Internal use only: Show a dialog that lets the user edit the dependency's
// description, or change its content by loading any file over top of the old
// content, or preview the current content in a new window.
export class Dependency extends Atom {

    static subclassName = Atom.registerSubclass( 'dependency', Dependency )
    
    /**
     * Shows a multi-part dialog for editing dependency atoms, including
     * specfying their description and providing their content in any one of a
     * variety of ways.  The user can then confirm or cancel the edit,
     * as per the convention described in {@link module:Atoms.Atom#edit the
     * edit() function for the Atom class}.
     * 
     * @returns {Promise} same convention as specified in
     *   {@link module:Atoms.Atom#edit edit() for Atoms}
     */
    edit () {
        const description = this.getMetadata( 'description' )
        const origContent = this.getHTMLMetadata( 'content' )?.innerHTML
        const origFilename = this.getMetadata( 'filename' ) || '(not yet loaded)'
        const origSource = this.getMetadata( 'source' ) || '(not yet loaded)'
        const autoRefresh = this.getMetadata( 'autoRefresh' )
        let newContent = origContent
        let newFilename = origFilename
        let newSource = origSource
        const dialog = new Dialog( 'Edit dependency', this.editor )
        dialog.addItem( new HTMLItem( `
            <p>Dependency loaded from:
            <tt id="dependencyFilename">${escapeHTML( origFilename )}</tt></p>
        ` ) )
        dialog.addItem( new ButtonItem( 'Load new contents', () => {
            Dialog.loadFile( this.editor, 'Load dependency contents' )
            .then( loaded => {
                const oldIsURL = newSource == 'web'
                const newIsURL = loaded.source == 'web'
                newFilename = loaded.filename
                newContent = loaded.content
                newSource = loaded.source
                const filenameSpan = dialog.querySelector( '#dependencyFilename' )
                filenameSpan.innerHTML = escapeHTML( newFilename )
                const checkbox = dialog.querySelector( 'input[type="checkbox"]' )
                if ( oldIsURL && !newIsURL )
                    checkbox.checked = false
                else if ( !oldIsURL && newIsURL )
                    checkbox.checked = true
                dialog.dialog.setEnabled( 'autoRefresh', newIsURL )
            } ) // save for below
            .catch( () => { } ) // sometimes URLs fail to load
        } ) )
        dialog.addItem( new ButtonItem( 'Preview current contents', () =>
            openFileInNewWindow( newContent ) ) )
        dialog.addItem( new CheckBoxItem( 'autoRefresh',
            'Re-import every time the document loads' ) )
        dialog.addItem( new TextInputItem( 'description', 'Description' ) )
        dialog.setDefaultFocus( 'description' )
        dialog.setInitialData( { description, autoRefresh } )
        const result = dialog.show().then( userHitOK => {
            if ( !userHitOK ) return false
            this.setMetadata( 'description', dialog.get( 'description' ) )
            this.setHTMLMetadata( 'content', newContent ) // save loaded content
            this.setMetadata( 'filename', newFilename ) // and where it came from
            this.setMetadata( 'source', newSource ) // and more of where it came from
            this.setMetadata( 'autoRefresh', dialog.get( 'autoRefresh' ) )
            this.update()
            return true
        } )
        dialog.dialog.setEnabled( 'autoRefresh', newSource == 'web' )
        return result
    }

    /**
     * Update the HTML representation of this dependency.  A dependency's
     * visual representation is just an uneditable DIV in the document that
     * looks like a box, says it's a dependency, and includes the description
     * the user provided when editing the dependency.  The actual content of the
     * dependency does not appear in its visual representation in the document,
     * because it would typically be prohibitively large.
     */
    update () {
        this.element.style.border = 'solid 1px gray'
        this.element.style.padding = '0 1em 0 1em'
        const description = this.getMetadata( 'description' )
        const filename = this.getMetadata( 'filename' )
        const source = this.getMetadata( 'source' )
        this.fillChild( 'body', simpleHTMLTable(
            'Imported dependency document',
            [ 'Description:', `<tt>${escapeHTML( description )}</tt>` ],
            [ 'Source:', `<tt>${escapeHTML( filename )}</tt> (${escapeHTML( source )})` ],
            [ 'Auto-refresh:', this.getMetadata( 'autoRefresh' ) ? 'yes' : 'no' ]
        ) )
    }

    /**
     * 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 dependency atoms.  It contains a single
     * line of text saying that a dependency is imported at this location,
     * followed by a bulleted list of the attributes of the dependency.
     * 
     * @returns {string} LaTeX representation of a dependency atom
     */
    toLatex () {
        return `Imported dependency document
        \\begin{enumerate}
        \\item  Description: ${escapeLatex( this.getMetadata( 'description' ) )}
        \\item  Source: \\url{${this.getMetadata( 'filename' )}}
        \\item  Auto-refresh: ${this.getMetadata( 'autoRefresh' ) ? 'yes' : 'no'}
        \\end{enumerate}
        `
    }

    /**
     * Get all top-level dependency atoms inside a given DOM node.
     * 
     * @param {Node} node - the DOM node in which to find Dependency atoms to
     *   refresh
     */
    static topLevelDependenciesIn ( node ) {
        // Find all elements inside the node representing dependency atoms
        const type = JSON.stringify( Dependency.subclassName )
        const allDepElts = Array.from( node.querySelectorAll(
            `.${className}[data-metadata_type='${type}']` ) )
        // Filter for just those that are top-level (not inside others)
        return allDepElts.filter( depElt =>
            !allDepElts.some( other =>
                other !== depElt && other.contains( depElt ) )
        ).map( depElt => Atom.from( depElt ) )
    }

    /**
     * Find all dependency atoms in the specified DOM node and refresh those for
     * which refreshing is possible.  A dependency atom can be refreshed if its
     * source is a URL.  The refreshing action on an individual dependency atom
     * is done by the {@link module:Dependencies.Dependency#refresh refresh()}
     * function.
     * 
     * If the second parameter is true, then not all URL-based dependencies are
     * refreshed, but only those whose "auto-refresh" checkbox is checked.
     * 
     * This process is recursive, in that after all dependency atoms have been
     * refreshed, it will call itself again to refresh all dependency atoms
     * found inside any of the dependency atoms that were just refreshed.
     * 
     * @param {Node} node - the DOM node in which to find Dependency atoms to
     *   refresh
     * @param {boolean} autoRefreshOnly - whether to refresh only those atoms
     *   representing dependencies whose "auto-refresh" checkbox is checked
     * @returns {Promise} a promise that resolves if all refreshable dependency
     *   atoms successfully refreshed, and that rejects if any of them failed to
     *   refresh (e.g., page no longer at that URL, or a network error, etc.)
     * @see {@link module:Dependencies.Dependency#refresh refresh()}
     */
    static refreshAllIn ( node, autoRefreshOnly = false ) {
        const topLevelDeps = Dependency.topLevelDependenciesIn( node )
        // Filter for just the refreshable ones (having an URL as their source)
        const toRefresh = topLevelDeps.filter( dependency =>
            dependency.getMetadata( 'source' ) == 'web' )
        // Return a promise that we will refresh each of them
        return Promise.all( toRefresh.map( dependency =>
            dependency.refresh( autoRefreshOnly ) ) )
    }

    /**
     * Refresh this dependency atom.  A dependency atom can be refreshed if its
     * source is a URL.  The auto-refresh checkbox need not be checked; that is
     * just for specifying whether this action should take place every time the
     * document loads.
     * 
     * This process is recursive, in that after the dependency atom has been
     * refreshed, it will call
     * {@link module:Dependencies.Dependency#refreshAllIn refreshAllIn()} to
     * refresh any dependencies inside the newly loaded content.  In doing so,
     * it will pass the argument of this function to specify whether that
     * recursion should apply to all URL-based dependencies, or just those whose
     * "auto-refresh" checkbox is checked.
     * 
     * @param {boolean} autoRefreshOnly - whether to ask recursive calls to
     *   apply to only dependencies whose "auto-refresh" checkbox is checked
     * @returns {Promise} a promise that resolves if the dependency was
     *   successfully refreshed, and that rejects if it failed to refresh
     *   (e.g., page no longer at that URL, or a network error, etc.)
     * @see {@link module:Dependencies.Dependency#refreshAllIn refreshAllIn()}
     */
    refresh ( autoRefreshOnly = false ) {
        return new Promise( ( resolve, reject ) => {
            loadFromURL( this.getMetadata( 'filename' ) ).then( content => {
                this.setHTMLMetadata( 'content', content )
                Dependency.refreshAllIn(
                    this.getHTMLMetadata( 'content' ), autoRefreshOnly
                ).then( resolve )
            } ).catch( reject )
        } )
    }

}

export default { install }