Lurch web app user interface

source

lurch-document.js


import { appURL, isValidURL } from './utilities.js'
import { appSettings } from './settings-install.js'
import {
    SettingsMetadata, SettingsCategoryMetadata, CategorySettingMetadata,
    TextSettingMetadata, LongTextSettingMetadata, BoolSettingMetadata
} from './settings-metadata.js'
import { Dependency } from './dependencies.js'
import { Dialog } from './dialog.js'
import { Atom } from './atoms.js'

// Load the app settings if we're in the browser, where we can do that, so that
// when we later try to use it, it actually contains the user's preferences.
if ( typeof localStorage !== 'undefined' ) appSettings.load()

/**
 * A Lurch document will have several parts, including at least the following.
 * 
 *  * The document content as the user sees it in the editor (HTML)
 *  * Any per-document settings stored in the document as metadata (JSON)
 *  * Information about dependencies on which the document depents (the form of
 *    which has not yet been decided)
 * 
 * When stored in a filesystem, the document must contain all three of these
 * parts, but when displayed in the editor, it should show only the first of
 * them, keeping the rest somewhere outside of the user's view.  And yet those
 * other parts will be editable by the user, through a UI not yet developed.
 * And so when the document is saved, the latest versions of all three parts of
 * the document must be saved, not just the part visible in the editor.
 * 
 * This class provides a clean API for moving documents between any filesystem
 * and the editor.  It is an ephemeral/disposable class, in the sense that you
 * will often create an instance just for use in one command, and then let it be
 * garbage collected.  Examples:
 * 
 * ```js
 * // Clear out the editor, as in response to File > New:
 * new LurchDocument( editor ).newDocument()
 * // Get the document from the editor, in a form ready to save
 * // (that is, including all of its parts, not just what's visible in the UI):
 * const doc = new LurchDocument( editor ).getDocument()
 * // Load into the editor a document retrieved from a filesystem, which will
 * // have all 3 parts described above, only one of which should be shown:
 * new LurchDocument( editor ).setDocument( doc )
 * ```
 */
export class LurchDocument {

    /**
     * Construct a new instance for reading and/or writing data and metadata
     * to and/or from the given editor.  Also, if the editor does not already
     * have a LurchDocument instance stored in its `lurchDocument` property,
     * place this new instance there.
     * 
     * @param {tinymce.editor} editor the editor with which this object will
     *   interface
     */
    constructor ( editor ) {
        this.editor = editor
        if ( !this.editor.lurchMetadata ) this.clearMetadata()
        if ( !this.editor.lurchDocument ) this.editor.lurchDocument = this
    }

    // Internal use only.  Clears out the editor's content.
    clearDocument () {
        this.editor.setContent( '' )
    }
    // Internal use only.  Sets up empty/new metadata structure.
    clearMetadata () {
        this.editor.lurchMetadata = this.editor.dom.doc.createElement( 'div' )
        this.editor.lurchMetadata.setAttribute( 'id', 'metadata' )
        this.editor.lurchMetadata.style.display = 'none'
        this.updateBodyClasses()
    }
    // Internal use only.  Ensure the editor has an element for showing a filename.
    getFilenameElement () {
        let filenameDisplay = document.getElementById( 'lurch-filename-display' )
        if ( !filenameDisplay ) {
            const menubar = document.querySelector( '.tox-menubar' )
            if ( !menubar )
                throw new Error( 'No TinyMCE menubar found in DOM' )
            filenameDisplay = document.createElement( 'div' )
            filenameDisplay.id = 'lurch-filename-display'
            filenameDisplay.classList.add( 'tox-mbtn' )
            filenameDisplay.style.color = '#aaaaaa'
            filenameDisplay.style.paddingLeft = '1rem'
            menubar.appendChild( filenameDisplay )
        }
        return filenameDisplay
    }

    /**
     * Clear out the contents of the editor given at construction time.  This
     * includes clearing out its content as well as any metdata, including
     * document settings and dependencies.  It also clears the editor's dirty
     * flag.
     */
    newDocument () {
        this.clearDocument()
        this.clearMetadata()
        this.clearFileID()
        this.editor.undoManager.clear()
        this.editor.setDirty( false )
    }

    /**
     * If the application loaded a file from a given filename, or a given online
     * storage location, it may want to save a unique ID (such as the filename
     * or a pointer to the online storage location) so that the user can later
     * just choose "Save" and have the file instantly stored back in the same
     * location.  To facilitate this, we allow the storing of an arbitrary ID
     * associated with the given file.  This ID is cleared out whenever
     * {@link LurchDocument#newDocument newDocument()} is called.
     * 
     * @param {any} id - the ID to store
     * @see {@link LurchDocument#getFileID getFileID()}
     * @see {@link LurchDocument#clearFileID clearFileID()}
     */
    setFileID ( id ) {
        this.editor.lastLurchFileID = id
        // The rest of this code is for showing the filename in the UI
        if ( id.filename ) id = id.filename
        if ( isValidURL( id ) ) id = id.split( '/' ).pop()
        id = id.replace( /\.lurch$/, '' )
        this.getFilenameElement().textContent = id
    }

    /**
     * See the description of {@link LurchDocument#setFileID setFileID()} for an
     * explanation of file IDs.  This function returns the current file ID if
     * there is one, or undefined otherwise.
     * 
     * @see {@link LurchDocument#setFileID setFileID()}
     * @see {@link LurchDocument#clearFileID clearFileID()}
     */
    getFileID () { return this.editor.lastLurchFileID }

    /**
     * See the description of {@link LurchDocument#setFileID setFileID()} for an
     * explanation of file IDs.  This function removes any file ID from this
     * document.  This function is called whenever
     * {@link LurchDocument#newDocument newDocument()} is called.
     * 
     * @see {@link LurchDocument#setFileID setFileID()}
     */
    clearFileID () {
        delete this.editor.lastLurchFileID
        this.getFilenameElement().textContent = ''
    }

    /**
     * A Lurch document has two main parts, a DIV storing the metadata followed
     * by a DIV storing the actual document content.  This function takes a
     * string containing the HTML for a Lurch document and extracts those two
     * components from it, returning each one as a fully constructed
     * `HTMLDivElement`.
     * 
     * Note that a Lurch document's HTML text also begins with a brief script to
     * create the link to open the document in the Lurch app, but that portion
     * of the input string is ignored, because it is not part of the document,
     * nor its metadata.
     * 
     * @param {string} document - the document as it was retrieved from a
     *   filesystem, ready to be loaded into this editor
     * @returns {Object} an object with `"metadata"` and `"document"` fields, as
     *   documented above
     * @see {@link LurchDocument#isDocumentHTML isDocumentHTML()}
     */
    static documentParts ( document ) {
        const temp = window.document.createElement( 'div' )
        temp.innerHTML = document
        const toSearch = Array.from( temp.childNodes )
        return {
            metadata : toSearch.find( child => child.matches?.( '#metadata' ) ),
            document : toSearch.find( child => child.matches?.( '#document' ) )
        }
    }

    /**
     * Is the given text a valid Lurch document?  This is checked by applying
     * the {@link LurchDocument#documentParts documentParts()} function to it,
     * and ensuring that it has at least a `document` member, even if it does
     * not also have a `metadata` member.
     * 
     * @param {string} document - the document in HTML form
     * @returns {boolean} true if the document is a valid Lurch document, false
     *   otherwise
     * @see {@link LurchDocument#documentParts documentParts()}
     */
    static isDocumentHTML ( document ) {
        return LurchDocument.documentParts( document ).document !== undefined
    }

    /**
     * Load the given document into the editor given at construction time.  This
     * will replace what's visible in the UI with the visible portion of the
     * given document, and will also replace the invisible document settings and
     * dependencies with those of the given document.  It also clears the
     * editor's dirty flag.
     * 
     * @param {string} document - the document as it was retrieved from a
     *   filesystem (or another source), ready to be loaded into this editor
     * @see {@link LurchDocument#getDocument getDocument()}
     */
    setDocument ( document ) {
        const parts = LurchDocument.documentParts( document )
        // There should be a metadata element; use it directly if so.
        if ( parts.metadata )
            this.editor.lurchMetadata = parts.metadata
        else
            this.clearMetadata()
        // There should be a document element; use its HTML content if so.
        if ( parts.document )
            this.editor.setContent( parts.document.innerHTML )
        else
            this.clearDocument()
        this.editor.undoManager.clear()
        this.editor.setDirty( false )
        // refresh any URL-based dependencies marked as "auto-refresh"
        Dependency.refreshAllIn( this.editor.lurchMetadata, true ).catch( error =>
            Dialog.notify( this.editor, 'error',
                `When auto-refreshing dependencies in header: ${error}` ) )
        Dependency.refreshAllIn( this.editor.getBody(), true ).catch( error =>
            Dialog.notify( this.editor, 'error',
                `When auto-refreshing dependencies in document: ${error}` ) )
        // If there are preview atoms in the document, remove them on load
        const existingPreviews = Atom.allIn( this.editor ).filter(
            atom => atom.getMetadata( 'type' ) == 'preview' )
        if ( existingPreviews.length > 0 ) {
            existingPreviews.forEach( preview => preview.element.remove() )
            this.editor.selection.setCursorLocation( this.editor.getBody(), 0 )
        }
    }
    
    /**
     * Return the document being edited by the editor that was given at
     * construction time.  This includes its visible content as well as its
     * metdata, which includes document settings and dependencies.  It may also
     * include a link at the top of the document, which allows the reader to
     * open the document in the live app from which it was saved.  That link can
     * be customized using the parameter.
     * 
     * @param {string|Function} openLink - the HTML content to use at the top of
     *   the document, to provide a link for opening the document in the live
     *   Lurch app.  If not provided, a sensible default is used, which is a DIV
     *   containing just one link, whose URL is supplied by a small script that
     *   runs at page load time and reads the document URL.  You can remove this
     *   link entirely by setting this value to the empty string.  If this is a
     *   function instead of a string, it will be called on the document content
     *   *without* the open link, and should return an open link to be used as a
     *   prefix.
     * @returns {string} the document in string form, ready to be stored in a
     *   filesystem
     * @see {@link LurchDocument#setDocument setDocument()}
     */
    getDocument ( openLink = LurchDocument.openLinkUsingURL ) {
        // Get the metadata and document as HTML strings
        const metadataHTML = this.editor.lurchMetadata.outerHTML
        const documentHTML = this.editor.getContent()
        // Use those to build the result
        const body = `
            ${metadataHTML}
            <div id="document">${documentHTML}</div>
        `
        // Prefix the open link and return the result
        return typeof( openLink ) == 'function' ? openLink( body ) + body :
               typeof( openLink ) == 'string' ? openLink + body : body
    }

    // Internal use only.  Default parameter value for getDocument().
    // Creates an open link that assumes the file is stored online somewhere,
    // and uses its current URL to construct the link.
    static openLinkUsingURL () {
        return `
            <div id="loadlink">
                <p><a>Open this file in the Lurch web app</a></p>
                <script language="javascript">
                    const link = document.querySelector( '#loadlink > p > a' )
                    const thisURL = encodeURIComponent( window.location.href )
                    link?.setAttribute( 'href', '${appURL()}?load=' + thisURL )
                </script>
            </div>
        `
    }

    // Internal use only.  Creates an open link that assumes the file is small
    // enough to be base-64 encoded into the URL query string.
    static openLinkUsingBase64 ( body ) {
        const data = encodeURIComponent( btoa( body ) )
        return `
            <div id="loadlink">
                <p><a>Open this file in the Lurch web app</a></p>
                <script language="javascript">
                    const link = document.querySelector( '#loadlink > p > a' )
                    const thisURL = encodeURIComponent( window.location.href )
                    link?.setAttribute( 'href', '${appURL()}?data=${data}' )
                </script>
            </div>
        `
    }

    // Internal use only.  Gets all HTML elements that store metadata.
    metadataElements () {
        return Array.from( this.editor.lurchMetadata.childNodes )
            .filter( element => element.tagName == 'DIV' )
    }
    // Internal use only.  Gets metadata element for a given key, if any.
    findMetadataElement ( category, key ) {
        return this.metadataElements().find( element =>
            element.dataset.category == category
         && element.dataset.key == key )
    }

    /**
     * Store a new piece of metadata in this object, or update an old one.
     * Pieces of metadata are indexed by a category-key pair, facilitating
     * "namespaces" within the metadata.  This is useful so that we can
     * partition the metadata into things like document-level settings, the
     * document's list of dependencies, data cached by algorithms in the app,
     * and any other categories that arise.
     * 
     * Values can be either JSON data (which includes strings, integers, and
     * booleans, in addition to the more complex types of JSON data) or HTML
     * in string form (for example, if you wish to store an entire dependency).
     * 
     * Also, some pieces of metadata, when stored, require placing attributes or
     * classes in the editor's DOM, and this function will take that action as
     * well, if needed.
     * 
     * @param {string} category - the category for this piece of metadata
     * @param {string} key - the key for this piece of metadata
     * @param {string} valueType - either "json" or "html" to specify the format
     *   for the value
     * @param {string|Object} value - a string of HTML if `valueType` is "html"
     *   or an object we can pass to `JSON.stringify()` if `valueType` is "json"
     * @see {@link LurchDocument#getMetadata getMetadata()}
     */
    setMetadata ( category, key, valueType, value ) {
        // Ensure correct value type
        if ( ![ 'json', 'html' ].includes( valueType.toLowerCase() ) )
            throw new Error( 'Invalid setting value type: ' + valueType )
        // Store the value
        const element = this.findMetadataElement( category, key )
        if ( element ) {
            element.dataset['valueType'] = valueType
            element.innerHTML = valueType == 'json' ? JSON.stringify( value ) : value
        } else {
            const newElement = this.editor.dom.doc.createElement( 'div' )
            newElement.dataset['category'] = category
            newElement.dataset['key'] = key
            newElement.dataset['valueType'] = valueType
            newElement.innerHTML = valueType == 'json' ? JSON.stringify( value ) : value
            this.editor.lurchMetadata.appendChild( newElement )
        }
        // Tweak editor DOM if needed
        this.updateBodyClasses()
    }

    /**
     * Pieces of metadata are indexed by a category-key pair, facilitating
     * "namespaces" within the metadata.  See {@link LurchDocument#setMetadata
     * setMetadata()} for more information on why.  This function looks up the
     * value that corresponds to the given category and key.
     * 
     * If the value is stored in JSON form, then the corresponding object will
     * be returned (as produced by `JSON.parse()`).  If the value is stored in
     * HTML form, then an `HTMLDivElement` instance will be returned, the
     * contents of which are the value of the metadata item.  The element
     * returned is a copy of the one stored internally, so the caller cannot
     * alter the internal value by modifying the returned element.
     * 
     * @param {string} category - the category for the piece of metadata to look
     *   up
     * @param {string} key - the key for the piece of metadata to look up
     * @returns {string|number|bool|Object|HTMLDivElement|undefined} the value
     *   stored in the metadata, or undefined if there is no such metadata
     * @see {@link LurchDocument#setMetadata setMetadata()}
     * @see {@link LurchDocument#getMetadataCategories getMetadataCategories()}
     * @see {@link LurchDocument#getMetadataKeys getMetadataKeys()}
     */
    getMetadata ( category, key ) {
        const element = this.findMetadataElement( category, key )
        const defaultValue = category == 'settings' ?
            LurchDocument.settingsMetadata.metadataFor( key )?.defaultValue :
            undefined
        return !element ? defaultValue :
               element.dataset.valueType == 'html' ? element.cloneNode( true ) :
               JSON.parse( element.innerHTML )
    }
    
    /**
     * Pieces of metadata are indexed by a category-key pair, facilitating
     * "namespaces" within the metadata.  See {@link LurchDocument#setMetadata
     * setMetadata()} for more information on why.  This function returns all
     * categories that appear in the document's metadata.  There is no defined
     * order to the result, but no category is repeated.  The list may be empty
     * if this document has no metadata stored in it.
     * 
     * @returns {string[]} an array containing all strings that appear as
     *   categories in this document's metadata
     * @see {@link LurchDocument#getMetadata getMetadata()}
     * @see {@link LurchDocument#getMetadataKeys getMetadataKeys()}
     */
    getMetadataCategories () {
        const result = [ ]
        this.metadataElements().forEach( element => {
            if ( !result.includes( element.dataset.category ) )
                result.push( element.dataset.category )
        } )
        return result
    }

    /**
     * Pieces of metadata are indexed by a category-key pair, facilitating
     * "namespaces" within the metadata.  See {@link LurchDocument#setMetadata
     * setMetadata()} for more information on why.  This function returns all
     * keys that appear in the document's metadata under a given category.
     * There is no defined order to the result, but no key is repeated.  The
     * list may be empty if this document has no metadata stored in it under the
     * given category.
     * 
     * @param {string} category - the category whose keys should be listed
     * @returns {string[]} the keys corresponding to the given category
     * @see {@link LurchDocument#getMetadata getMetadata()}
     * @see {@link LurchDocument#getMetadataCategories getMetadataCategories()}
     */
    getMetadataKeys ( category ) {
        const result = [ ]
        this.metadataElements().forEach( element => {
            if ( element.dataset.category == category
              && !result.includes( element.dataset.key ) )
                result.push( element.dataset.key )
        } )
        return result
    }

    /**
     * Pieces of metadata are indexed by a category-key pair, facilitating
     * "namespaces" within the metadata.  See {@link LurchDocument#setMetadata
     * setMetadata()} for more information on why.  This function deletes the
     * unique metadata item stored under the given category-key pair if there is
     * one, and does nothing otherwise.
     * 
     * Also, some pieces of metadata, when deleted, require placing attributes
     * or classes in the editor's DOM, and this function will take that action
     * as well, if needed.
     * 
     * @param {string} category - the category for the piece of metadata to
     *   delete
     * @param {string} key - the key for the piece of metadata to delete
     * @see {@link LurchDocument#getMetadata getMetadata()}
     * @see {@link LurchDocument#setMetadata setMetadata()}
     */
    deleteMetadata ( category, key ) {
        // Delete metadata
        const element = this.findMetadataElement( category, key )
        if ( element ) element.remove()
        // Tweak editor DOM if needed
        this.updateBodyClasses()
    }

    /**
     * This metadata object can be used to create a {@link Settings} instance
     * for any given document, which can then present a UI to the user for
     * editing the document's settings (using
     * {@link Settings#userEdit its userEdit() function}).  We use it for this
     * purpose in the menu item we create in the
     * {@link module:DocumentSettings.install install()} function, among other
     * places.  Instances of this class also use it to return the appropriate
     * defaults for settings the user may query about a document.
     * 
     * This metadata can be used to edit document-level settings, which are
     * distinct from the application-level settings defined in
     * {@link module:SettingsInstaller the Settings Installer module}.
     */
    static settingsMetadata = new SettingsMetadata(
        new SettingsCategoryMetadata(
            'Document metadata',
            new TextSettingMetadata( 'title', 'Title', '' ),
            new TextSettingMetadata( 'author', 'Author', '' ),
            new TextSettingMetadata( 'date', 'Date', '' ),
            new LongTextSettingMetadata( 'abstract', 'Abstract', '' )
        ),
        new SettingsCategoryMetadata(
            'Math content',
            new CategorySettingMetadata(
                'notation',
                'Default notation to use for new expressions',
                [ 'Lurch notation', 'LaTeX' ],
                appSettings.get( 'notation' )
            ),
            new CategorySettingMetadata(
                'shell style',
                'Style for displaying environments',
                [ 'boxed', 'minimal' ],
                appSettings.get( 'default shell style' )
            )
        ),
        new SettingsCategoryMetadata(
          'Validation options',
          new BoolSettingMetadata( 'instantiateEverything', 'Try harder to validate (can be much slower)', false )
       )
    )

    /**
     * This array lists those settings that should be marked as classes on the
     * body element of the editor's document.  This exposes them to CSS rules
     * in the editor, so that they can be used to style the document content.
     * 
     * In general, a setting with key "example one" and value "foo bar" will be
     * marked on the body element with a class of "example-one-foo-bar".
     * 
     * @see {@link LurchDocument#updateBodyClasses updateBodyClasses()}
     */
    static bodySettings = [ 'shell style' ]

    /**
     * For each setting mentioned in {@link LurchDocument#bodySettings
     * bodySettings}, this function ensures that there is precisely one CSS
     * class on the body of the document beginning with that setting's key,
     * and that is the class that ends with that setting's value.
     * 
     * As documented in {@link LurchDocument#bodySettings bodySettings}, spaces
     * are replaced with dashes, so that a setting with key "number of tacos"
     * and value "not enough" would become a CSS class
     * "number-of-tacos-not-enough".
     * 
     * @see {@link LurchDocument#bodySettings bodySettings}
     */
    updateBodyClasses () {
        LurchDocument.bodySettings.forEach( settingKey => {
            const prefix = settingKey.replace( ' ', '-' ) + '-'
            const value = this.getMetadata( 'settings', settingKey )
            const newClass = prefix + value.replace( ' ', '-' )
            const oldClasses = Array.from( this.editor.dom.doc.body.classList )
            oldClasses.forEach( oldClass => {
                if ( oldClass.startsWith( prefix ) && oldClass != newClass )
                    this.editor.dom.doc.body.classList.remove( oldClass )
            } )
            if ( !oldClasses.includes( newClass ) )
                this.editor.dom.doc.body.classList.add( newClass )
        } )
    }

}