/**
* Users who want to edit the invisible header inside of a Lurch document (which
* is stored in its metadata) can do so in a second browser window (or tab)
* containing a full Lurch document editor just for the header of the original
* document. In such a scheme, we call the first window (containing the whole
* document) the *primary window* or *primary copy of the app* and the second
* window (containing just the header from the primary window) *secondary
* window* or *secondary copy of the app.*
*
* This module adds features for both the primary and secondary copies of the
* app. For the primary window, it implements the tools for launching the
* secondary window and sending it the header data. For the secondary window,
* it it implements the tools for limiting the UI elements to only what are
* needed for the secondary copy of the app, and a function for passing the
* edited header information back to the primary window upon request from the
* user.
*
* @module HeaderEditor
*/
import { appURL } from './utilities.js'
import { LurchDocument } from './lurch-document.js'
import { appSettings } from './settings-install.js'
import {
Dialog, DialogRow, HTMLItem, ButtonItem, TextInputItem
} from './dialog.js'
import { Dependency } from './dependencies.js'
import { autoOpenLink } from './load-from-url.js'
import { Atom } from './atoms.js'
/**
* The metadata element for a document is stored in the editor rather than the
* DOM, because we do not want TinyMCE to be able to edit it. It is sometimes
* useful to be able to extract the header element from that metadata, so that
* it can be treated like an entire document (fragment), since it effectively is\
* one.
*
* @param {tinymce.Editor} editor - the editor from which to extract the
* document header
* @returns {HTMLElement} the HTMLElement that contains the document header
* for this editor
* @function
*/
export const getHeader = editor =>
new LurchDocument( editor ).getMetadata( 'main', 'header' )
// For internal use only: Extract the header from the document metadata, as a
// string of HTML
const getHeaderHTML = editor => {
const result = getHeader( editor )
return result ? result.innerHTML : ''
}
// For internal use only: Save the given HTML text into the document metadata
// as the document's header
export const setHeader = ( editor, header ) =>
new LurchDocument( editor ).setMetadata( 'main', 'header', 'html', header )
// Internal constant used in URL query strings to tell a copy of the app that it
// has been opened for the sole purpose of being a header editor for a different
// copy of the app. If this shows up in the query string, then the Lurch app
// being launched knows to configure itself differently to support header
// editing rather than full document editing. In particular, "Save" should send
// the header back to the original document, not save it as a new document.
const headerFlag = 'editHeader'
/**
* Detect whether the current copy of the app running in this window is the
* secondary one, created in service to another (primary) window elsewhere.
* In other words, return true if this is the secondary window and false if it
* is the primary one.
*
* @returns {boolean} whether this app window is for editing the document
* header from a separate (primary) Lurch app window
* @function
*/
export const isEditor = () =>
new URL( window.location ).searchParams.get( headerFlag ) == 'true'
/**
* Detect whether the current copy of the app running in this window is the
* primary window *and also* currently has a secondary window open for editing
* this window's header.
*
* @returns {boolean} whether this app window has a secondary window open for
* editing the header in this window's document
*/
export const hasEditorOpen = () =>
window.headerEditorWindow && !window.headerEditorWindow.closed
/**
* Install into a TinyMCE editor instance the menu items that can be used in
* the primary window to pop open the secondary window, or instead to move
* content between the header and the main document. The menu items in question
* are intended for the Document menu, but could be placed anywhere.
*
* @param {tinymce.editor} editor - the TinyMCE editor into which to install the
* tools
* @function
*/
export const install = editor => {
editor.ui.registry.addMenuItem( 'editheader', {
text : 'Edit document header in new window',
icon : 'new-tab',
tooltip : 'Edit document header',
onAction : () => {
if ( hasEditorOpen() )
return Dialog.notify( editor, 'warning',
'You are already editing this document\'s header in another window.' )
window.headerEditorWindow = window.open(
`${appURL()}?${headerFlag}=true`, '_blank' )
// We cannot tell when the header editor window is ready to receive
// messages, so we have to retry sending our content until either it
// lets us know that it received it, or the tab closes.
const interval = setInterval( () => {
if ( window.closed ) {
clearInterval( interval )
return
}
window.headerEditorWindow.postMessage(
getHeaderHTML( editor ), appURL() )
}, 1000 )
window.addEventListener( 'message', event => {
if ( event.source != window.headerEditorWindow ) return
// if it's a message saying they received our content, stop
// trying to send it
if ( event.data == 'content received' ) {
clearInterval( interval )
return
}
// otherwise, assume it's a "save" message with new content,
// because the user edited the header in the other window and
// then saved, which sends it back to us
setHeader( editor, event.data )
Dialog.notify( editor, 'success', 'Header updated from other window.', 5000 )
}, false )
}
} )
editor.ui.registry.addMenuItem( 'extractheader', {
text : 'Move header into document',
icon : 'chevron-down',
tooltip : 'Extract header to top of document',
onAction : () => {
if ( hasEditorOpen() )
return Dialog.notify( editor, 'error',
'You cannot extract the header while editing it in another window.' )
const header = getHeaderHTML( editor )
if ( header == '' )
return Dialog.notify( editor, 'warning',
'This document\'s header is currently empty.' )
appSettings.load()
appSettings.showWarning( 'warn before extract header', editor )
.then( () => {
editor.selection.setCursorLocation() // == start
editor.insertContent( header )
setHeader( editor, '' )
editor.undoManager.clear()
} )
.catch( () => { } )
}
} )
editor.ui.registry.addMenuItem( 'embedheader', {
text : 'Move selection to end of header',
icon : 'chevron-up',
tooltip : 'Embed selection from document to end of header',
onAction : () => {
if ( hasEditorOpen() )
return Dialog.notify( editor, 'error',
'You cannot extract the header while editing it in another window.' )
const toEmbed = editor.selection.getContent()
if ( hasEditorOpen() )
return Dialog.notify( editor, 'error',
'You do not currently have any content selected.' )
appSettings.load()
appSettings.showWarning( 'warn before embed header', editor )
.then( () => {
setHeader( editor, getHeaderHTML( editor ) + toEmbed )
editor.execCommand( 'delete' )
editor.undoManager.clear()
} )
.catch( () => { } )
}
} )
editor.ui.registry.addMenuItem( 'editdependencyurls', {
text : 'Edit background material',
tooltip : 'Edit the list of documents on which this one depends',
icon : 'edit-block',
onAction : () => {
// Get all dependency information from the document
let header = getHeader( editor ) // important! this is a clone!
const relevantDependencies = !header ? [ ] :
Dependency.topLevelDependenciesIn( header ).filter(
dependency => dependency.getMetadata( 'source' ) == 'web' )
const chosenDependencyURLs = relevantDependencies.map(
dependency => dependency.getMetadata( 'filename' ) )
// Create the dialog, but it is a dynamic dialog, so we do not
// populate it directly, but instead create functions that will do
// so as needed.
const dialog = new Dialog( 'Edit background material', editor )
// This first function handles the width of column 1 in the dialog's
// rows, to improve aesthetics. It must be called after each
// dynamic update to the dialog. (See calls below.)
const touchUpDialogDOM = () => {
dialog.querySelector( 'input[type="text"]' )
.classList.add( 'expand-this' )
;[ ...dialog.querySelectorAll( '.expand-this' ) ].forEach(
node => node.parentNode.style.width = '100%' )
}
// This function clears out all dialog content and repopulates the
// dialog based on which dependency URLs are currently chosen.
// The list of dependency URLs starts out as whatever the document
// currently contains, as computed above, but will be edited over
// time by the user, using this dialog, before hitting OK or Cancel.
const fillDialog = () => {
while ( dialog.items.length > 0 ) dialog.removeItem( 0 )
dialog.addItem( new HTMLItem( 'Existing background material:' ) )
// Add 0 or more rows, one for each URL:
if ( chosenDependencyURLs.length == 0 ) {
dialog.addItem( new HTMLItem( '(none)' ) )
} else {
chosenDependencyURLs.forEach( ( url, index ) => {
dialog.addItem( new DialogRow(
new HTMLItem( `<code class="expand-this">${url}</code>` ),
new ButtonItem(
'View',
() => window.open( autoOpenLink( url ), '_blank' ),
`view${index}`
),
new ButtonItem(
'Remove',
() => {
chosenDependencyURLs.splice( index, 1 )
fillDialog()
dialog.reload()
touchUpDialogDOM()
},
`remove${index}`
)
) )
} )
}
// Add controls for adding another URL:
dialog.addItem( new HTMLItem( ' ' ) )
dialog.addItem( new HTMLItem( 'To add new background material:' ) )
dialog.addItem( new DialogRow(
new TextInputItem(
'new_url', '', 'Enter URL here' ),
new ButtonItem( 'Add', () => {
const newURL = dialog.get( 'new_url' )
if ( newURL.trim() == '' ) return
chosenDependencyURLs.push( newURL )
fillDialog()
dialog.reload()
touchUpDialogDOM()
} )
) )
dialog.setDefaultFocus( 'new_url' )
}
// Call the above function to populate the dialog for the first time.
fillDialog()
// Show the dialog then handle what happens when the user does Cancel/OK.
dialog.show().then( userHitOK => {
if ( !userHitOK ) return
// The user hit OK, so we make changes to the document.
// If it doesn't have a header, create a new, empty one.
// Note that `header` is a DOM clone of the actual header!
if ( !header ) {
setHeader( editor, '' )
header = getHeader( editor )
}
// Delete any old dependency atoms from the (copy of the) header:
relevantDependencies.forEach(
dependency => dependency.element.remove() )
// Add the new list of dependency atoms to the end of the
// (copy of the) header:
chosenDependencyURLs.forEach( url => {
const newDependency = Atom.newBlock( editor, '', {
type : 'dependency',
description : 'none',
filename : url,
source : 'web',
content : '', // will be populated later; see below
autoRefresh : true
} )
newDependency.update()
header.appendChild( newDependency.element )
} )
// Because "header" is a clone of the actual header, the
// in-place edits above did not touch the actual document,
// so we must do the following to "save" our changes:
setHeader( editor, header.innerHTML )
// This is the code that recursively populates header dependencies:
// (It is a bit of a hack because we are using the "private"
// method findMetadataElement(), but it's what we need.)
const savedHeader = new LurchDocument( editor )
.findMetadataElement( 'main', 'header' )
Dependency.refreshAllIn( savedHeader ).then( () => {
Dialog.notify( editor, 'success',
'Refreshed all background material from the web.',
5000 )
} ).catch( error => {
Dialog.notify( editor, 'error',
'Could not refresh all background material from the web.' )
console.log( 'Error when refreshing background material',
error )
} )
} )
// Now that we've shown the dialog for the first time,
// do the necessary tweaks to make its aesthetics right:
touchUpDialogDOM()
}
} )
editor.ui.registry.addMenuItem( 'viewdependencyurls', {
text : 'Show/hide rules',
icon : 'character-count',
shortcut : 'meta+alt+0',
tooltip : 'View the mathematical content on which this document depends',
onAction : () => {
// If there are preview atoms in the document, remove them and be done
const existingPreviews = Atom.allIn( editor ).filter(
atom => atom.getMetadata( 'type' ) == 'preview' )
if ( existingPreviews.length > 0 ) {
existingPreviews.forEach( preview => preview.element.remove() )
editor.selection.setCursorLocation(editor.getBody(),0)
return
}
// If not, we have to create them from the content in the header.
// If there is no content in the header, report that and be done.
const header = getHeader( editor )
if ( !header ) {
Dialog.notify( editor, 'warning',
'This document does not import any background material.',
5000 )
return
}
// Accumulate the HTML representation of all previews of all
// dependencies in the header.
let allPreviewHTML = ''
Dependency.topLevelDependenciesIn( header ).forEach( dependency => {
const preview = Atom.newBlock( editor, '', { type : 'preview' } )
preview.imitate( dependency )
allPreviewHTML += preview.element.outerHTML
} )
// Insert it into the document.
editor.selection.setCursorLocation() // == start
editor.insertContent( allPreviewHTML )
editor.undoManager.clear()
editor.selection.setCursorLocation() // deselect new insertions
}
} )
}
/**
* Assuming that we're in the secondary copy of the app, listen for the message
* from the primary window that sends us the header to edit, and when we receive
* it, populate our editor with it. While we wait, our editor is read only and
* says "Loading header..." so that the user knows to wait.
*
* Also, install a new File > Save action that will send our editor's content
* back to the primary window so that it can store that updated content in its
* document header.
*
* @param {tinymce.editor} editor - the TinyMCE editor into which to load the
* header data, once we receive it from the primary window
* @function
*/
export const listen = editor => {
let mainEditor = null
editor.setContent( 'Loading header...' )
editor.mode.set( 'readonly' )
window.addEventListener( 'message', event => {
if ( !appURL().startsWith( event.origin ) ) return
mainEditor = event.source
new LurchDocument( editor ).newDocument()
editor.setContent( event.data )
editor.mode.set( 'design' )
Dialog.notify( editor, 'success',
'Opened header data for editing.\nDon\'t forget to save before closing.' )
mainEditor.postMessage( 'content received', appURL() )
}, false )
editor.ui.registry.addMenuItem( 'savedocument', {
text : 'Save',
tooltip : 'Save header into original document',
icon : 'save',
shortcut : 'meta+S',
onAction : () => {
if ( !mainEditor ) return
mainEditor.postMessage( editor.getContent(), appURL() )
}
} )
}
export default { isEditor, hasEditorOpen, install, listen }
source