Lurch web app user interface

source

upload-download.js


/**
 * This file provides a function for initiating the download of a file, plus a
 * {@link Dialog} component for initiating the upload of a file.
 * 
 * @module DownloadUpload
 */

import { LurchDocument } from './lurch-document.js'
import { isValidURL } from './utilities.js'

/**
 * Immediately initiates the download of the contents of the Lurch document
 * stored in the given TinyMCE editor.  Technically, it creates an invisible
 * link that would initiate the download, clicks that link, and then discards
 * it.
 * 
 * If a filename is not provided, it is lifted from the document, and if none is
 * there, a default one is provided.
 * 
 * @param {tinymce.editor} editor - the editor whose contents should be
 *   downloaded
 * @param {string} [filename] - the initial name of the file to include in the
 *   download dialog (though the user can change this)
 */
export const downloadFile = ( editor, filename ) => {
    const LD = new LurchDocument( editor )
    const content = LD.getDocument()
    const anchor = document.createElement( 'a' )
    anchor.setAttribute( 'href', 'data:text/html;charset=utf-8,'
        + encodeURIComponent( content ) )
    if ( !filename )
        filename = LD.getFileID() || 'lurch-document.lurch'
    if ( filename.startsWith( 'file:///' ) )
        filename = filename.slice( 8 )
    else if ( isValidURL( filename ) )
        filename = filename.split( '/' ).pop()
    anchor.setAttribute( 'download', filename )
    document.body.appendChild( anchor )
    anchor.click()
    anchor.remove()
}

/**
 * An item that can be used in a {@link Dialog} to allow the user to upload a
 * file.  It creates HTML content with two DIVs, one that permits dragging and
 * dropping of files onto the dialog to upload them, and the other of which
 * contains a button you can click to choose a file.
 * 
 * Whenever the user chooses a file, the file's information are stored in the
 * `uploadedName` and `uploadedContent` properties of the object, and if the
 * instance implements the `.onFileChanged()` method, that method is called.
 */
export class UploadItem {

    /**
     * Construct an upload control/area, as described above.
     * 
     * @param {string} name - the key to use to identify this input control's
     *   content in the dialog's key-value mapping for all input controls
     */
    constructor ( name ) {
        this.name = name
        this.style = `
            width: 100%;
            height: 100px;
            border: 1px solid #aaaaaa;
            display: flex;
            justify-content: center;
            align-content: center;
            flex-direction: column;
            padding: 1em;
        `.replace( /\n/g, ' ' )
    }

    // internal use only; creates the JSON to represent this object to TinyMCE
    json () {
        return [ {
            type : 'htmlpanel',
            html : `
                <div id='drop_${this.name}' style='${this.style}'></div>
                <div style='${this.style}'>
                    <p>Option 2:
                    <input type='file' id='choose_${this.name}' accept=".lurch"/></p>
                </div>
            `
        } ]
    }

    // internal use only for finding/manipulating HTML elements unique to this class
    zone () { return document.getElementById( `drop_${this.name}` ) }
    // internal use only for finding/manipulating HTML elements unique to this class
    resetZone () {
        this.zone().innerHTML = 'Option 1: Drag and drop a file here.'
        this.zone().style.removeProperty( 'background' )
    }
    // internal use only for finding/manipulating HTML elements unique to this class
    input () { return document.getElementById( `choose_${this.name}` ) }
    // internal use only for finding/manipulating HTML elements unique to this class
    resetInput () {
        this.input().value = null
    }

    // internal use only for storing a file the user uploaded, for later retrieval
    setFile ( file ) {
        return file.text().then( content => {
            this.uploadedName = file.name
            this.uploadedContent = content
            this.onFileChanged?.()
        } )
    }

    // internal use only; styles the HTML components and installs event handlers
    onShow () {
        const zone = this.zone()
        // Handle basic events for styling and preventing file opening
        zone.addEventListener( 'dragover', event => event.preventDefault() )
        zone.addEventListener( 'dragenter', _ =>
            zone.style.background = '#eeeeff' )
        zone.addEventListener( 'dragleave', _ =>
            zone.style.removeProperty( 'background' ) )
        // If the user drops a file, upload it into a local variable
        zone.addEventListener( 'drop', event => {
            event.preventDefault()
            const dropped = event.dataTransfer.items ?
                Array.from( event.dataTransfer.items )
                    .filter( item => item.kind == 'file' ) :
                Array.from( event.dataTransfer.files )
            if ( dropped.length > 0 ) {
                this.setFile( dropped[0].getAsFile() ).then( () => {
                    zone.innerHTML = 'File uploaded.'
                    this.resetInput()
                } )
            }
        } )
        this.resetZone()
        const input = this.input()
        // If the user chooses a file, store it in a local variable
        input.addEventListener( 'change', () => {
            if ( input.files.length > 0 ) {
                this.setFile( input.files[0] ).then( () => {
                    this.resetZone()
                } )
            }
        } )
        this.resetInput()
    }

    // internal use only; returns a filename-and-content object if requested by
    // the dialog's get() function
    get ( key ) {
        if ( key == this.name ) return {
            filename : `file:///${ this.uploadedName }`,
            content : this.uploadedContent,
            source : 'upload'
        }
    }

}