Lurch web app user interface

source

dialog.js


import { UploadItem } from './upload-download.js'
import { appSettings } from './settings-install.js'
import { syntaxTreeHTML, putdownHTML } from './notation.js'

/**
 * This class makes it easier to create and use TinyMCE dialogs that have
 * components that work well with Lurch.  It handles a lot of the boilerplate
 * code that TinyMCE demands and it allows us to work in a more object-oriented
 * way, rather than passing around large pieces of JSON and writing all of a
 * dialog's code in one big function call.
 */
export class Dialog {

    /**
     * Create (but do not yet show) a dialog box associated with a given editor.
     * 
     * @param {string} title - the title to display at the top of the dialog
     * @param {tinymce.Editor} editor - the editor in which to create the dialog
     */
    constructor ( title, editor ) {
        this.editor = editor
        this.json = {
            title,
            body : {
                type : 'panel',
                items : [ ]
            },
            buttons : [
                {
                    name : 'OK',
                    text : 'OK',
                    type : 'submit',
                    buttonType : 'primary'
                },
                {
                    name : 'Cancel',
                    text : 'Cancel',
                    type : 'cancel'
                }
            ],
            onChange : ( ...args ) => this.onChange?.( ...args ),
            onAction : ( ...args ) => {
                this.items.forEach( item => item.onAction?.( ...args ) )
            },
            onSubmit : () => {
                this.dialog?.close()
                this.resolver?.( true )
            },
            onCancel : () => {
                this.dialog?.close()
                this.resolver?.( false )
            },
            onTabChange : ( _, details ) => {
                this.currentTabName = details.newTabName
                this.element = Dialog.getTopDialogElement()
                this.runItemShowHandlers()
            }
        }
        this.dialog = null
        this.currentTabName = null
        this.items = [ ]
        this.focusItem = null
        this.hideHeader = false
        this.hideFooter = false
    }

    /**
     * Close this dialog box and resolve any open promise with a "false"
     * argument, as if the user had canceled the dialog.
     */
    close () {
        this.dialog?.close()
        this.resolver?.( false )
    }

    /**
     * TinyMCE dialogs allow you to specify any set of buttons for the dialog's
     * footer.  By default, every dialog this class displays will have an OK
     * button for submitting the dialog and a Cancel button for canceling it.
     * You can replace those buttons by calling this function, passing an array
     * of JSON objects, one for each button you wish to define.
     * 
     * For example, to use just an OK button, you might do the following
     * (although you could accomplish it more easily with
     * {@link Dialog#removeButton removeButton()} instead).
     * 
     * ```js
     * myDialog.setButtons( [
     *     { text : 'OK', type : 'submit', buttonType : 'primary' }
     * ] )
     * ```
     * 
     * @param  {any[]} buttons - the JSON code for the buttons in the dialog's
     *   footer
     * @see {@link Dialog#removeButton removeButton()}
     */
    setButtons ( ...buttons ) {
        this.json.buttons = buttons
    }

    /**
     * Remove one of the buttons in this dialog.  Especially useful if the
     * dialog is for informational purposes only, and doesn't need a "cancel"
     * button.  You could call `myDialog.removeButton( 'Cancel' )`.
     * 
     * @param {string} text - the text shown on the button to be removed
     * @see {@link Dialog#setButtons setButtons()}
     */
    removeButton ( text ) {
        this.json.buttons = this.json.buttons.filter(
            button => button.text != text )
    }

    /**
     * If you want to keep a submit button but not have it named with its
     * default text of "OK" you can use this function to rename that default
     * button.  It will still function as a submit button.
     * 
     * @param {string} text - the new text to use in place of "OK"
     */
    setOK ( text ) {
        const button = this.json.buttons.find( button =>
            button.type == 'submit' && button.buttonType == 'primary' )
        if ( button ) button.text = text
    }

    /**
     * Any TinyMCE dialog can have an object mapping control IDs to values, to
     * fill the dialog's input controls with their initial values.  To specify
     * that mapping, use this function.  Its keys should be the names of any
     * input controls you've added to this dialog using
     * {@link Dialog#addItem addItem()}.
     * 
     * @param {Object} data - the mapping from control names to values
     */
    setInitialData ( data ) {
        this.json.initialData = data
    }

    /**
     * Dialogs are not, by default, split into tabs, but are just one large
     * panel of controls.  You can split them into a set of tabs by calling
     * this function and providing the names of the tabs you want created.
     * 
     * @param  {string[]} titles - the titles of the tabs
     * @see {@link Dialog#currentTabTitle currentTabTitle()}
     * @see {@link Dialog#showTab showTab()}
     * @see {@link Dialog#removeTabs removeTabs()}
     */
    setTabs ( ...titles ) {
        this.json.body = {
            type : 'tabpanel',
            tabs : titles.map( title => {
                return {
                    name : title.replace( /[^a-zA-Z]/g, '' ),
                    title : title,
                    items : [ ]
                }
            } )
        }
    }

    /**
     * This is the reverse operation of {@link Dialog#setTabs setTabs()}.  It
     * removes all tabs, thus collapsing all items onto one pane of the dialog.
     * This is almost never needed, because you typically set up a dialog once,
     * then use it, and don't need to change its structure in this way.
     * 
     * @see {@link Dialog#setTabs setTabs()}
     */
    removeTabs () {
        this.json.body = { type : 'panel', items : [ ] }
    }

    /**
     * If you have split your dialog into tabs using the {@link Dialog#setTabs
     * setTabs()} function, and shown your dialog, this function will report the
     * title shown on the top of the currently visible tab.
     * 
     * @returns {string} the title of the currently shown tab
     * @see {@link Dialog#setTabs setTabs()}
     * @see {@link Dialog#showTab showTab()}
     */
    currentTabTitle () {
        const n = this.json.body.tabs ? this.json.body.tabs.length : 0
        for ( let i = 0 ; i < n ; i++ )
            if ( this.json.body.tabs[i].name == this.currentTabName )
                return this.json.body.tabs[i].title
    }

    /**
     * If you have split your dialog into tabs using the {@link Dialog#setTabs
     * setTabs()} function, and shown your dialog, you can call this function to
     * change which tab is visible, specifying the new one to show by its title.
     * 
     * @param {string} title - the title of the tab you want to switch to
     */
    showTab ( title ) {
        const n = this.json.body.tabs ? this.json.body.tabs.length : 0
        for ( let i = 0 ; i < n ; i++ )
            if ( this.json.body.tabs[i].title == title )
                return this.dialog.showTab( this.json.body.tabs[i].name )
    }

    /**
     * Add an item to the dialog.  There is not a specific class defined for
     * dialog items, because they need provide only a `.json()` method that
     * creates the JSON code for all the body components the item adds to the
     * dialog.  However, items can optionally also provide `.onAction()` and
     * `.onShow()` event handlers, as well as a `.get()` event that can be more
     * flexible than `dialog.getData()['arg']`.
     * 
     * @param {Object} item - the item to add, any object that has a `.json()`
     *   method that creates JSON code appropriate for TinyMCE dialogs
     * @param {string} tabTitle - the tab into which to insert the item, if the
     *   dialog is split into tabs; leave this blank if it is not
     * @see {@link AlertItem}
     * @see {@link HTMLItem}
     * @see {@link TextInputItem}
     * @see {@link ButtonItem}
     * @see {@link UploadItem}
     * @see {@link SelectBoxItem}
     * @see {@link Dialog#removeItem removeItem()}
     */
    addItem ( item, tabTitle = null ) {
        if ( !this.items.includes( item ) ) {
            this.items.push( item )
            item.dialog = this
        }
        item._generated_json = item.json()
        if ( !tabTitle )
            return item._generated_json.forEach(
                item => this.json.body.items.push( item ) )
        const tab = this.json.body.tabs.find( tab => tab.title == tabTitle )
        if ( !tab )
            throw new Error( `Invalid tab: ${tabTitle}` )
        item.tab = tab.name
        item._generated_json.forEach( item => tab.items.push( item ) )
    }

    /**
     * Remove an item from the dialog, and also remove from the dialog's
     * internal JSON data structure any JSON items created from the item that
     * is being removed.
     * 
     * @param {integer} index - the index of the item to remove
     * @see {@link Dialog#addItem addItem()}
     */
    removeItem ( index ) {
        if ( this.json.body.tabs ) {
            const tab = this.json.body.tabs.find(
                tab => tab.name == this.items[index].tab )
            tab.items = tab.items.filter( item =>
                !this.items[index]._generated_json.includes( item ) )
        } else {
            this.json.body.items = this.json.body.items.filter( item =>
                !this.items[index]._generated_json.includes( item ) )
        }
        this.items.splice( index, 1 )
    }

    /**
     * Show the dialog, run any `.onShow()` event handlers in any items in the
     * dialog, and return a promise.  That promise resolves when the user
     * submits or cancels the dialog, and returns true if it was a submit and
     * false if it was a cancel.
     * 
     * @returns {Promise} a promise that resolves when the dialog closes or
     *   encounters an error
     */
    show () {
        if ( this.json.body.tabs )
            this.currentTabName = this.json.body.tabs[0].name
        this.dialog = this.editor.windowManager.open( this.json )
        this.element = Dialog.getTopDialogElement()
        if ( this.hideHeader )
            this.querySelector( '.tox-dialog__header' ).style.display = 'none'
        if ( this.hideFooter )
            this.querySelector( '.tox-dialog__footer' ).style.display = 'none'
        return new Promise( ( resolve, reject ) => {
            this.resolver = resolve
            this.rejecter = reject
            this.runItemShowHandlers()
        } ).finally( () => {
            delete this.element
        } )
    }

    /**
     * Specify which control should receive focus when the dialog is first
     * shown.  You can pass `null` to clear this setting.
     * 
     * @param {string} name - the name of the control to focus when the dialog
     *   is shown
     */
    setDefaultFocus ( name ) { this.focusItem = name }

    // For internal use only; see show().
    runItemShowHandlers () {
        if ( this.json.body.tabs )
            this.items.forEach( item => {
                if ( item.tab == this.currentTabName )
                    item.onShow?.( this.dialog )
            } )
        else
            this.items.forEach( item => item.onShow?.( this.dialog ) )
        if ( this.focusItem )
            setTimeout( () => this.dialog.focus( this.focusItem ) )
    }

    /**
     * This is similar to TinyMCE's `dialog.getData()[key]` syntax, but more
     * flexible in that before resorting to that fallback method, it first asks
     * each item in the dialog if it wants to return a value for the given key.
     * This lets those items behave in a more sophisticated and flexible manner
     * if they choose to.  If none of them returns a value different from
     * `undefined`, then we fall back on the standard TinyMCE method shown
     * above.  If the dialog is not shown, this returns undefined.
     * 
     * @param {string} key - the key whose value should be looked up
     * @returns {any} the corresponding value
     */
    get ( key ) {
        if ( !this.dialog ) return undefined
        const data = this.dialog.getData()
        for ( let i = 0 ; i < this.items.length ; i++ ) {
            if ( !this.items[i].get ) continue
            const maybe = this.items[i].get( key, data )
            if ( maybe !== undefined ) return maybe
        }
        return data[key]
    }

    /**
     * Ask TinyMCE to regenerate the dialog's content from its JSON encoding.
     */
    reload () {
        this.dialog?.redial( this.json )
        this.runItemShowHandlers()
    }

    /**
     * Behaves exactly like `document.querySelector()`, except that it is run on
     * just the element representing this dialog box, and thus will return only
     * an element that appears in this dialog.  Or, if this dialog has no
     * element matching the given selector, it returns undefined.
     * 
     * @param {string} selector - the CSS selector to use for the query
     * @returns {HTMLElement} the first element that matches the selector
     * @see {@link Dialog#querySelectorAll querySelectorAll()}
     * @see {@link Dialog.getTopDialogElement getTopDialogElement()}
     */
    querySelector ( selector ) {
        return this.element?.querySelector( selector )
    }

    /**
     * Behaves exactly like `document.querySelectorAll()`, except that it is run
     * on just the element representing this dialog box, and thus will return
     * only elements that appear in this dialog.  Or, if this dialog has no
     * element matching the given selector, it returns an empty node list.
     * 
     * @param {string} selector - the CSS selector to use for the query
     * @returns {NodeList} the list of elements that match the selector
     * @see {@link Dialog#querySelector querySelector()}
     * @see {@link Dialog.getTopDialogElement getTopDialogElement()}
     */
    querySelectorAll ( selector ) {
        return this.element?.querySelectorAll( selector )
    }

    /**
     * TinyMCE may have open zero or more dialog boxes at any given time.  This
     * method returns the HTML element (a DIV) corresponding to the topmost
     * dialog box, or undefined if there are no open dialogs.
     * 
     * Whenever an instance of this class is placed on screen using its
     * {@link Dialog#show show()} method, this method is used to notice which
     * DOM element represents that dialog, and it is stored in the dialog's
     * `element` field for later use by functions like
     * {@link Dialog#querySelector querySelector()} and
     * {@link Dialog#querySelectorAll querySelectorAll()}.
     * 
     * @returns {HTMLDivElement} the DIV element in the DOM representing the
     *   topmost TinyMCE dialog
     */
    static getTopDialogElement () {
        const allDialogElements =
            document.querySelectorAll( 'div.tox-dialog[role="dialog"]' )
        return allDialogElements[allDialogElements.length - 1]
    }

    /**
     * A static method that creates a dialog with just an OK button and a
     * success message in it.  This is a convenience method that makes it
     * possible to show a success message in a modal dialog using just one line
     * of code.
     * 
     * @param {tinymce.Editor} editor - the editor over which to show the dialog
     * @param {string} text - the success message to be displayed
     * @param {string} [title="Success"] - an optional title for the dialog
     * @returns {Promise} a promise that resolves when the dialog closes, the
     *   result of a call to {@link Dialog#show show()}
     * @see {@link Dialog.failure Dialog.failure()}
     * @see {@link Dialog.areYouSure Dialog.areYouSure()}
     */
    static success ( editor, text, title = 'Success' ) {
        const dialog = new Dialog( title, editor )
        dialog.removeButton( 'Cancel' )
        dialog.addItem( new AlertItem( 'success', text ) )
        return dialog.show()
    }

    /**
     * A static method that creates a dialog with just an OK button and a
     * failure message in it.  This is a convenience method that makes it
     * possible to show a failure message in a modal dialog using just one line
     * of code.
     * 
     * @param {tinymce.Editor} editor - the editor over which to show the dialog
     * @param {string} text - the failure message to be displayed
     * @param {string} [title="Failure"] - an optional title for the dialog
     * @returns {Promise} a promise that resolves when the dialog closes, the
     *   result of a call to {@link Dialog#show show()}
     * @see {@link Dialog.success Dialog.success()}
     * @see {@link Dialog.areYouSure Dialog.areYouSure()}
     */
    static failure ( editor, text, title = 'Failure' ) {
        const dialog = new Dialog( title, editor )
        dialog.removeButton( 'Cancel' )
        dialog.addItem( new AlertItem( 'error', text ) )
        return dialog.show()
    }

    /**
     * A static method that creates a dialog entitled "Are you sure?" and
     * prompting the user with a warning containing the given text.  The return
     * value is a promise that resolves with a boolean argument indicating
     * whether the user clicked yes/I'm sure (true) or no/Cancel (false).
     * 
     * @param {tinymce.Editor} editor - the editor over which to show the dialog
     * @param {string} text - the question to be displayed
     * @returns {Promise} a promise that resolves when the dialog closes, the
     *   result of a call to {@link Dialog#show show()}
     * @see {@link Dialog.success Dialog.success()}
     * @see {@link Dialog.failure Dialog.failure()}
     */
    static areYouSure ( editor, text ) {
        const dialog = new Dialog( 'Are you sure?', editor )
        dialog.addItem( new AlertItem( 'warn', text ) )
        dialog.setOK( 'I\'m sure' )
        return dialog.show()
    }

    /**
     * Pop up a notification over the editor.  This can be done with TinyMCE's
     * `NotificationManager` class, but this function just makes it slightly
     * more convenient, because the parameters are named, and one does not need
     * to remember the JSON encoding of them.
     * 
     * @param {tinymce.Editor} editor - the editor in which to show the
     *   notification
     * @param {string} type - the type of notification to show, which TinyMCE
     *   requires must be one of "success", "info", "warning", or "error"
     * @param {string} text - the content of the notification
     * @param {integer} timeout - how long (in ms) until the notification
     *   disappears (optional; defaults to 2000 for success notifications and
     *   no timeout for everything else)
     */
    static notify ( editor, type, text, timeout ) {
        if ( type == 'success' )
            timeout ||= 2000
        if ( ![ 'success', 'info', 'warning', 'error' ].includes( type ) )
            throw new Error( 'Invalid notification type: ' + type )
        editor.notificationManager.open( {
            type : type,
            text : text,
            timeout : timeout
        } )
    }

    /**
     * Show the meaning of any atom in the editor in a dialog, as long as the
     * atom has a meaning as a LogicConcept.  This will use the representations
     * defined in {@link module:Notation.syntaxTreeHTML syntaxTreeHTML()} and
     * {@link module:Notation.putdownHTML putdownHTML()}, each in a separate tab
     * of the dialog.
     * 
     * @param {Atom} atom - the Atom whose meaning should be shown
     */
    static meaningOfAtom ( atom ) {
        const result = new Dialog( 'Meaning', atom.editor )
        result.removeButton( 'Cancel' )
        result.setTabs( 'Hierarchy', 'Code' )
        const LCs = atom.toLCs()
        result.addItem( new HTMLItem( syntaxTreeHTML( LCs ) ), 'Hierarchy' )
        result.addItem( new HTMLItem( putdownHTML( LCs ) ), 'Code' )
        result.show()
        setTimeout( () =>
            result.showTab( appSettings.get( 'preferred meaning style' ) ) )
    }

}

/**
 * An item that can be used in a {@link Dialog} to represent an alert (that is,
 * a piece of text that is colored and given an icon to make it more obvious).
 * This corresponds to the "alertbanner" type of body component in a TinyMCE
 * dialog.
 */
export class AlertItem {

    /**
     * Construct an alert item.  An appropriate icon is chosen to correspond to
     * the type of alert constructed.
     * 
     * @param {string} type - the type of alert, one of "info", "warn", "error",
     *   or "success"
     * @param {string} text - the text to show in the alert
     */
    constructor ( type, text ) {
        this.type = type
        this.text = text        
    }

    // internal use only; creates the JSON to represent this object to TinyMCE
    json () {
        return [ {
            type : 'alertbanner',
            text : this.text,
            level : this.type,
            icon : this.type == 'success' ? 'selected' :
                   this.type == 'warn' ? 'warning' :
                   this.type == 'info' ? 'info' : 'notice'
        } ]
    }

}

/**
 * An item that can be used in a {@link Dialog} to represent any HTML content.
 * This corresponds to the "htmlpanel" type of body component in a TinyMCE
 * dialog.
 */
export class HTMLItem {

    /**
     * Construct an HTML item.
     * 
     * @param {string} html - the HTML code of the content to show
     */
    constructor ( html ) {
        this.html = html
    }

    // internal use only; creates the JSON to represent this object to TinyMCE
    json () {
        return [ { type : 'htmlpanel', html : this.html } ]
    }

}

/**
 * An item that can be used in a {@link Dialog} to represent a blank text box
 * into which the user can type content.  This corresponds to the "input" type
 * of body component in a TinyMCE dialog.
 */
export class TextInputItem {

    /**
     * Construct a new text input control.
     * 
     * @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
     * @param {string} label - the text to place next to the input control to
     *   explain it to the user
     * @param {string} placeholder - optional, the text to include inside the
     *   control when it is blank, as an example
     */
    constructor ( name, label, placeholder ) {
        this.name = name
        this.label = label
        this.placeholder = placeholder
    }

    // internal use only; creates the JSON to represent this object to TinyMCE
    json () {
        const result = {
            type : 'input',
            name : this.name,
            label : this.label
        }
        if ( this.placeholder )
            result.placeholder = this.placeholder
        return [ result ]
    }

}

/**
 * Functions just like {@link TextInputItem}, except it is an HTML textarea,
 * and thus can contain multiple lines of input text.
 */
export class LongTextInputItem {

    /**
     * Construct a new long text input control.
     * 
     * @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
     * @param {string} label - the text to place above the input control to
     *   explain it to the user
     * @param {string} placeholder - optional, the text to include inside the
     *   control when it is blank, as an example
     */
    constructor ( name, label, placeholder ) {
        this.name = name
        this.label = label
        this.placeholder = placeholder
    }

    // internal use only; creates the JSON to represent this object to TinyMCE
    json () {
        const result = {
            type : 'textarea',
            name : this.name,
            label : this.label
        }
        if ( this.placeholder )
            result.placeholder = this.placeholder
        return [ result ]
    }

}

/**
 * An item that can be used in a {@link Dialog} and shows up as a clickable
 * button.  This corresponds to the "button" type of body component in a TinyMCE
 * dialog.
 */
export class ButtonItem {

    /**
     * Construct a button.
     * 
     * @param {string} text - the text shown on the button
     * @param {function} action - the function to call when the button is
     *   clicked; it will be passed the {@link Dialog} instance
     * @param [string] name - the name of the button, internally (will default
     *   to the text parameter, but if you have multiple buttons with the same
     *   text, TinyMCE requires they all have different names internally)
     */
    constructor ( text, action, name ) {
        this.text = text
        this.action = action
        this.name = ( name || text ).replace( /[^_a-zA-Z0-9]/g, '_' )
    }

    // internal use only; creates the JSON to represent this object to TinyMCE
    json () {
        return [ {
            type : 'button',
            name : this.name,
            text : this.text,
            maximized : true
        } ]
    }

    // internal use only; calls this button's action if it is pressed
    onAction ( dialog, details ) {
        if ( details.name == this.name ) this.action( dialog )
    }

    /**
     * Find the element in the DOM representing this button.  This works only if
     * the dialog containing the button has been shown on screen, not before.
     * 
     * @return {HTMLElement} the DOM element with tag "BUTTON"
     */
    getElement () {
        return this.dialog.querySelector( `button[title="${this.text}"]` )
    }

}

/**
 * An item that can be used in a {@link Dialog} and shows up as a dropdown list
 * of options.  This corresponds to the "selectbox" type of body component in
 * a TinyMCE dialog.
 */
export class SelectBoxItem {
    
    /**
     * Construct a select box.
     * 
     * @param {string} name - the name of the control in the dialog, used for
     *   querying its value when the dialog closes, or providing an initial
     *   value when the dialog opens
     * @param {string} label - the label to show next to the select box in the
     *   user interface
     * @param {string[]} items - the array of items to be shown in the select
     *   box in the user interface
     */
    constructor ( name, label, items ) {
        this.name = name
        this.label = label
        this.items = items
    }

    // internal use only; creates the JSON to represent this object to TinyMCE
    json () {
        return [ {
            type : 'selectbox',
            name : this.name,
            label : this.label,
            items : this.items.map( name => {
                return { value : name, text : name }
            } )
        } ]
    }

}

/**
 * An item that can be used in a {@link Dialog} and shows up as a checkbox.
 * This corresponds to the "checkbox" type of body component in a TinyMCE
 * dialog.
 */
export class CheckBoxItem {

    /**
     * Construct a checkbox.
     * 
     * @param {string} name - the name of the control in the dialog, used for
     *   querying its value when the dialog closes, or providing an initial
     *   value when the dialog opens
     * @param {string} label - the label to show next to the checkbox in the
     *   user interface
     */
    constructor ( name, label ) {
        this.name = name
        this.label = label
    }

    // internal use only; creates the JSON to represent this object to TinyMCE
    json () {
        return [ {
            type : 'checkbox',
            name : this.name,
            label : this.label
        } ]
    }

}

/**
 * An item that can be used in a {@link Dialog} and takes up the full width of
 * the dialog, but can be populated with an array of inner items, that will be
 * arranged in that row.  This corresponds to the "bar" type of body component
 * in a TinyMCE dialog.  By default, all columns in the row take up the same
 * amount of space, but you can make one larger if you modify the style of the
 * element after it has been placed into the DOM, setting one of the
 * sub-elements to have `width: 100%`.
 */
export class DialogRow {

    /**
     * Construct a dialog row.
     * 
     * @param {...Object} items - an array of dialog items (e.g.,
     *   {@link AlertItem} or {@link ButtonItem}) to place into this row
     */
    constructor ( ...items ) {
        this.items = items
    }

    // internal use only; creates the JSON to represent this object to TinyMCE
    json () {
        return [ {
            type : 'bar',
            items : this.items.map( item => item.json() ).flat()
        } ]
    }

    // internal use only; pass any notifications on to my children
    onAction ( ...args ) {
        this.items.forEach( item => item.onAction?.( ...args ) )
    }
    onShow ( ...args ) {
        this.items.forEach( item => {
            item.dialog = this.dialog
            item.onShow?.( ...args )
        } )
    }

}

/**
 * An item that can be used in a {@link Dialog} to place a label above one or
 * more other dialog items.  This corresponds to the "label" type of body
 * component in a TinyMCE dialog.
 */
export class LabeledGroup {

    /**
     * Construct a labeled group.
     * 
     * @param {string} caption - the caption to show above the group
     * @param {...Object} items - an array of dialog items (e.g.,
     *   {@link AlertItem} or {@link ButtonItem}) to place into this group
     */
    constructor ( caption, ...items ) {
        this.caption = caption
        this.items = items
    }

    // internal use only; creates the JSON to represent this object to TinyMCE
    json () {
        return [ {
            type : 'label',
            label : this.caption,
            items : this.items.map( item => item.json() ).flat()
        } ]
    }

    // internal use only; pass any notifications on to my children
    onAction ( ...args ) {
        this.items.forEach( item => item.onAction?.( ...args ) )
    }
    onShow ( ...args ) {
        this.items.forEach( item => {
            item.dialog = this.dialog
            item.onShow?.( ...args )
        } )
    }

}

/**
 * An item that can be used in a {@link Dialog} and shows up as a list of
 * one-line items with HTML contents (each one a DIV with a light border).
 * Items can be selected, clicked, or double-clicked, and handlers can be added
 * for each such situation.  The value of the item is the selected item's
 * value, or undefined if no item is selected.
 * 
 * It supports several event handlers that users can implement by defining
 * functions with the following names on the instance they create:
 * 
 *  - `onClick(value)` - called when the item is clicked, with the item's value
 *    as the one parameter
 *  - `onDoubleClick(value)` - called when the item is double-clicked, with the
 *    item's value as the one parameter
 *  - `onSelectionChanged()` - called when the selection changes, no parameters
 *    passed
 *  - `onTextShown()` - called after repopulating the list with plain text,
 *    because a client called {@link ListItem#showText showText()}
 *  - `onListShown()` - called after repopulating the list with items, because
 *    a client called {@link ListItem#showList showList()}
 */
export class ListItem {

    static mainDivId = 'dialog-list-item'
    static itemDivClass = 'dialog-list-item-one-item'

    /**
     * Construct a new list item.  Later you can either fill it with items, by
     * calling {@link ListItem#showList showList()}, or fill it with plain text
     * (e.g., as a temporary message like "Loading..." or a message like "No
     * items to show") by calling {@link ListItem#showText showText()}.
     */
    constructor ( name ) {
        this.name = name
        this.names = [ ]
        this.values = [ ]
        this.itemsAreSelectable = false
        this.selectedItem = null
        this.minHeight = '100px'
        this.maxHeight = '200px'
    }

    /**
     * Switch the mode of this item to permit selecting items (`on` true) or not
     * permit selecting items (`on` false).  Turning this off removes the
     * current selection, if there is one.
     * 
     * @param {boolean} on - whether items are selectable
     */
    setSelectable ( on = true ) {
        this.itemsAreSelectable = on
        if ( !on ) this.selectItem( null, null )
    }

    // internal use only; selects an item
    selectItem ( itemDiv, itemValue ) {
        this.dialog.querySelectorAll( `.${ListItem.itemDivClass}` ).forEach(
            div => div.style.backgroundColor = '' )
        if ( itemDiv ) itemDiv.style.backgroundColor = 'lightblue'
        this.selectedItem = itemValue
        this.onSelectionChanged?.()
    }

    // internal use only; creates the JSON to represent this object to TinyMCE
    json () {
        return [ {
            type : 'htmlpanel',
            html : `<div id="${ListItem.mainDivId}-${this.name}"
                style="border: solid 1px #006CE7;
                padding: 0.5em;
                border-radius: 6px;
                min-height: ${this.minHeight};
                max-height: ${this.maxHeight};
                overflow-y: scroll;"></div>`
        } ]
    }

    // internal use only
    getMainDiv () {
        return this.dialog.querySelector( `#${ListItem.mainDivId}-${this.name}` )
    }

    // internal use only; change contents of this DIV to text
    showText ( text ) {
        const element = this.getMainDiv()
        if ( !element ) return
        this.selectedItem = null
        element.innerHTML = text
        this.onTextShown?.()
        if ( this.itemsAreSelectable ) this.selectItem( null, null )
    }

    // internal use only; change contents of this DIV to a list of items
    showList ( names, values ) {
        if ( values.length == 0 )
            values = names
        if ( names.length != values.length )
            throw new Error( 'Names and values must be the same length' )
        this.names = names
        this.values = values
        this.selectedItem = null
        const panel = this.getMainDiv()
        if ( !panel ) return
        panel.innerHTML = ''
        names.forEach( ( name, index ) => {
            const value = values[index]
            // put a new item into the panel and give it content
            const itemDiv = panel.ownerDocument.createElement( 'div' )
            itemDiv.classList.add( ListItem.itemDivClass )
            panel.appendChild( itemDiv )
            itemDiv.innerHTML = name
            // style it
            itemDiv.style.padding = '5px 0'
            itemDiv.style.cursor = 'default'
            itemDiv.width = '100%'
            // Add event handlers to the item
            itemDiv.addEventListener( 'click', () => setTimeout( () => {
                // don't let the second half of a double-click count also count
                // as an individual click
                if ( this.justDoubleClicked ) {
                    this.justDoubleClicked = false
                    return
                }
                // If the dialog has closed since the click, do nothing
                if ( !this.dialog.element )
                    return
                // If items are selectable, update the selection
                if ( this.itemsAreSelectable ) {
                    if ( this.selectedItem == value )
                        this.selectItem( null, null )
                    else
                        this.selectItem( itemDiv, value )
                }
                // Let users install an onClick() handler
                this.onClick?.( value )
            } ) )
            itemDiv.addEventListener( 'dblclick', () => {
                // Let users install an onDoubleClick() handler
                this.onDoubleClick?.( value )
                // Prevent the double-click from also triggering a click
                this.justDoubleClicked = true
            } )
        } )
        this.onListShown?.()
        if ( this.itemsAreSelectable ) this.selectItem( null, null )
    }

    // internal use only; returns a file object if requested by the dialog's
    // get() function
    get ( key, _ ) {
        if ( key == this.name ) return this.selectedItem
    }

}