/**
* In a Lurch document, certain sections will be marked as "atoms." These may
* be an inline section represented by an HTML span or a block section
* represented by an HTML div. Atoms have the following properties.
*
* 1. The user cannot *directly* edit their content. Rather, the application
* determines how the atom appears in the document. (There are also
* meaningful sections of the document that are not indivisible in this way;
* see {@link module:Shells the Shells module}.)
* 2. The user can *indirectly* edit the atom's content by clicking on it and
* interacting with whatever dialog the application pops up in response.
* 3. There can be many different types of atoms. For example, one atom may be
* a mathematical equation while another atom adds an attribute to the
* document that contains it; these are two different types, and there will
* be other types as well. The type is stored as an attribute of the atom's
* HTML element.
* 4. Each atom will typically have some meaning that will be important when
* the document is processed in a mathematical way.
*
* Atoms are represented in the document by spans and divs with a certain class
* attached to mark them as atoms, and they are also have their
* `contenteditable` property set to false, which TinyMCE respects so that the
* user cannot edit their content (though they can cut, copy, paste, or delete
* them).
*
* This module contains tools for working with atoms, including the
* {@link module:Atoms.className class name} we use to distinguish them, the
* {@link module:Atoms.install function} we use to install their event handlers,
* and most importantly, the {@link module:Atoms.Atom class} we use to create an
* API for working with individual atoms.
*
* @module Atoms
* @see {@link module:Shells the Shells module}
*/
import { removeScriptTags, isOnScreen, editorForNode } from './utilities.js'
import { getConverter } from './math-live.js'
/**
* Class name used to distinguish HTML elements representing atoms. (For an
* explanation of what an atom is, see the documentation for
* {@link module:Atoms the module itself}.)
*/
export const className = 'lurch-atom'
// Internal use only: simplify consistent use of class and attribute names
const metadataKey = key => `metadata_${key}`
const isMetadataKey = key => key.startsWith( 'metadata_' )
const innerMetadataKey = key => key.substring( 9 )
const childClass = type => `${className}-${type}`
const childSelector = type => '.' + childClass( type )
/**
* For information about the concept of atoms in Lurch in general, see the
* documentation of {@link module:Atoms the Atoms module}. Because atoms are HTML
* elements, their API is that provided by the browser for all HTML elements,
* and is not specific to their role as atoms. To provide an API that makes it
* easier to deal with atoms in a Lurch document, we create this class.
*
* One simply constructs an instance of this class, passing the corresponding
* HTML element from within the editor, along with the editor itself, and the
* resulting object provides an extensive API (documented below) for interfacing
* with the atom in a variety of ways useful for the Lurch app.
*
* This is analogous to how we deal with shells in the editor, using the
* {@link Shells} class.
*/
export class Atom {
// Internal use only: Stores a mapping from subclass names to subclasses of
// the Atom class. Public use of this data should be done through the
// registerSubclass() function below; clients do not need to read this data.
static subclasses = new Map()
/**
* This class tracks its collection of subclasses so that elements in the
* editor can have an appropriate Atom subclass wrapper created around them
* as needed, for custom event handling. To register a subclass, call this
* function. To create an atom that has the right subclass, see
* {@link module:Atoms.Atom#from from()}.
*
* Example:
*
* ```js
* class Example extends Atom { ... }
* Atom.registerSubclass( 'Example', Example )
* ```
*
* @param {string} name - the name of the subclass to register
* @param {Object} subclass - the subclass itself
*/
static registerSubclass ( name, subclass ) {
Atom.subclasses.set( name, subclass )
return name
}
/**
* Construct a new instance of this class corresponding to the atom
* represented by the given `HTMLElement`. Recall that the purpose of this
* class, as documented above, is to provide an API for consistent and
* convenient use of atoms, an API that is not part of the `HTMLElement`
* API. Thus to use that API, you use this constructor and then call
* functions on the resulting object. The intent is for such instances to
* be ephemeral, in the sense that you can create one, use it, and let it be
* garbage collected immediately thereafter, with little performance cost.
*
* @param {HTMLElement} element - the element in the editor (a span or div)
* representing the atom
* @param {tinymce.Editor} editor - the editor in which the element sits
*/
constructor ( element, editor ) {
if ( !Atom.isAtomElement( element ) )
throw new Error( 'This is not an atom element: ' + element )
this.element = element
this.editor = editor
}
/**
* This function is a handler for whenever the metadata stored in this atom
* changes. Its default implementation is to do nothing, but having it here
* allows {@link module:Validation the validation module} to replace this
* handler with one that clears all validation feedback. We do it this way,
* rather than importing the validation module and calling its "clear"
* function ourselves, because it prevents a circular dependency.
*
* @see {@link Atom#wasDeleted wasDeleted()}
*/
dataChanged () { }
/**
* This function is a handler for whenever an atom has just been deleted
* from the document. Its default implementation is to do nothing, but
* having it here allows {@link module:Validation the validation module} to
* replace this handler with one that clears all validation feedback. We do
* it this way, rather than importing the validation module and calling its
* "clear" function ourselves, because it prevents a circular dependency.
*
* @see {@link Atom#dataChanged dataChanged()}
*/
wasDeleted () { }
/**
* Get the HTML representation of this atom, as it currently sits in the
* document. There is no corresponding `setHTML()` function; instead, see
* {@link module:Atoms.Atom#fillChild fillChild()}.
*
* @returns {string} the HTML representation of this atom
* @see {@link module:Shells.Shell#getHTML getHTML()}
*/
getHTML () { return this.element.outerHTML }
/**
* Set (or clear) the hover text on this atom. Hover text is what's shown
* in a small popup when the user hovers over the atom in the editor.
*
* @param {string?} text - the text to set as the hover text for this atom's
* HTMLElement, or `null` to remove the hover text
*/
setHoverText ( text ) {
if ( text )
this.element.setAttribute( 'title', text )
else
this.element.removeAttribute( 'title' )
}
/**
* An atom has the following three or four internal parts, called children.
* If the atom is inline (a span) then each of the children is represented
* as an inner span, but if the atom is a block (a div) then each of the
* children is an inner div.
*
* 1. The "metadata" child, which exists only in block-type atoms, and can
* contain arbitrarily large HTML metadata, none of which is displayed
* in the document for the user to see. Clients should not read from or
* write to this HTML metadata storage directly, but should instead use
* the functions in this class designed for that purpose, including
* {@link module:Atoms.Atom#getHTMLMetadata getHTMLMetadata()} and
* {@link module:Atoms.Atom#setHTMLMetadata setHTMLMetadata()}. The
* reason that this exists only in block-type atoms is because one
* cannot put arbitrary HTML inside a span, as one can with a div.
* 2. The "prefix" child, which is the first child that is visible to the
* user in the document. This may be omitted or empty, but in those
* situations where the atom should have some decoration on its left
* side (for inline atoms) or its top (for block atoms), such content
* can be placed in the prefix. The client rarely needs to write to the
* prefix directly, but can instead use the
* {@link module:Atoms.Atom#fillChild fillChild()} function to do so.
* You can use this function to read from the prefix when needed.
* 3. The "body" child, which is where the main content of the atom should
* go. For example, if the atom represents a mathematical equation, the
* typeset version of that equation can go here. Again, to specify the
* body content, use {@link module:Atoms.Atom#fillChild fillChild()}.
* 4. The "suffix" child, which functions just like the prefix, except
* appears at the end (right side for inline atoms, bottom for block
* atoms). Again, to specify the suffix content, use
* {@link module:Atoms.Atom#fillChild fillChild()}.
*
* When you call this function, both parameters are optional. If you do not
* specify the type of child, it defaults to the body, and if the child you
* request does not exist, it defaults to creating it for you. (In a brand
* new atom, there are no children; it is empty.) If you do not wish the
* child to be created, but rather receive `undefined` as the result if it
* does not exist, set the second parameter to `false`.
*
* @param {string} type - which type of child to fetch, must be one of
* `"metadata"`, `"prefix"`, `"body"`, or `"suffix"` (default is `"body"`)
* @param {boolean} createIfNeeded - whether to create the child in question
* if it does not already exist (default is `true`)
* @returns {HTMLElement} the child of the given type inside this atom
* @see {@link module:Atoms.Atom#fillChild fillChild()}
* @see {@link module:Atoms.Atom#removeChild removeChild()}
*/
getChild ( type = 'body', createIfNeeded = true ) {
// determine whether the type is valid and where it should be placed
const sequence = [ 'metadata', 'prefix', 'body', 'suffix' ]
const index = sequence.indexOf( type )
if ( index == -1 ) // we also use this index further below
throw new Error( 'Invalid child type: ' + type )
if ( type == 'metadata' && this.element.tagName == 'SPAN' )
throw new Error( 'Inline atoms cannot have a metadata child' )
// return an existing child if there is one with the requested type
const result = Array.from( this.element.childNodes ).find(
child => child.matches?.( childSelector( type ) ) )
if ( result ) return result
// if we are not allowed to create a new child, stop here
if ( !createIfNeeded ) return undefined
// create a new child of the given type
const newChild = this.element.ownerDocument.createElement(
this.element.tagName )
newChild.classList.add( childClass( type ) )
if ( type == 'metadata' ) newChild.style.display = 'none'
// place it where it belongs in the sequence declared above
for ( let i = index + 1 ; i < sequence.length ; i++ ) {
const next = this.getChild( sequence[i], false )
if ( next ) {
this.element.insertBefore( newChild, next )
break
}
}
if ( newChild.parentNode != this.element )
this.element.appendChild( newChild )
// if TinyMCE added a random placeholder, that can now be removed
Array.from( this.element.childNodes ).find( element =>
element.tagName == 'BR' && element.dataset.mceBogus )?.remove()
// return the newly created child
return newChild
}
/**
* Fill the child of the specified type with the given HTML content. If the
* child does not yet exist, create it before populating it. See the
* documentation for {@link module:Atoms.Atom#getChild getChild()} for an
* explanation of the children of atoms.
*
* @param {string} type - which type of child to write to; see the
* documentation for {@link module:Atoms.Atom#getChild getChild()} for a
* list of the valid children types
* @param {string} html - the HTML code to use to fill the child
* @see {@link module:Atoms.Atom#getChild getChild()}
* @see {@link module:Atoms.Atom#removeChild removeChild()}
*/
fillChild ( type, html ) { this.getChild( type ).innerHTML = html }
/**
* Remove the child with the specified type (or do nothing if there is no
* such child). See the documentation for {@link module:Atoms.Atom#getChild
* getChild()} for an explanation of the children of atoms.
*
* @param {string} type - which type of child to write to; see the
* documentation for {@link module:Atoms.Atom#getChild getChild()} for a
* list of the valid children types
* @see {@link module:Atoms.Atom#getChild getChild()}
* @see {@link module:Atoms.Atom#fillChild fillChild()}
*/
removeChild ( type ) { this.getChild( type, false )?.remove() }
/**
* Look up a metadata entry in this atom using the given key. Atoms can
* contain metadata mapping any string key to any JSONable value. Thus this
* function will return a JSONable object, as extracted from the given key,
* if that key indeed appears in this atom's metadata.
*
* This type of metadata is stored in the `dataset` property of the
* `HTMLElement` representing the atom. This is a natural way to store
* small amounts of data about the atom. For larger amounts of data, you
* may wish to use {@link module:Atoms.Atom#getHTMLMetadata
* getHTMLMetadata()} instead.
*
* @param {string} key - the key whose value should be looked up
* @returns {any} the data associated with the given key, or undefined if
* the key is not in the metadata
* @see {@link module:Atoms.Atom#setMetadata setMetadata()}
* @see {@link module:Atoms.Atom#removeMetadata removeMetadata()}
* @see {@link module:Atoms.Atom#getMetadataKeys getMetadataKeys()}
* @see {@link module:Atoms.Atom#getHTMLMetadata getHTMLMetadata()}
*/
getMetadata ( key ) {
const json = this.element.dataset[metadataKey( key )]
return json ? JSON.parse( json ) : json
}
/**
* Store a metadata entry in this atom under the given key. Atoms can
* contain metadata mapping any string key to any JSONable value, so you
* should provide a value that is amenable to JSON encoding. It will be
* stored using its JSON encoding, as a string.
*
* If the caller omits the `value` parameter, or sets it to undefined, this
* function does nothing.
*
* @param {string} key - the key under which to store the value
* @param {any} value - the value to store
* @see {@link module:Atoms.Atom#getMetadata getMetadata()}
* @see {@link module:Atoms.Atom#removeMetadata removeMetadata()}
* @see {@link module:Atoms.Atom#getMetadataKeys getMetadataKeys()}
* @see {@link module:Atoms.Atom#setHTMLMetadata setHTMLMetadata()}
*/
setMetadata ( key, value ) {
if ( value === undefined ) return
this.element.dataset[metadataKey( key )] = JSON.stringify( value )
this.dataChanged()
}
/**
* Remove a metadata entry from this atom, with the given key.
*
* @param {string} key - the key that (together with its value) should be
* removed
* @see {@link module:Atoms.Atom#getMetadata getMetadata()}
* @see {@link module:Atoms.Atom#setMetadata setMetadata()}
* @see {@link module:Atoms.Atom#getMetadataKeys getMetadataKeys()}
* @see {@link module:Atoms.Atom#removeHTMLMetadata removeHTMLMetadata()}
*/
removeMetadata ( key ) {
delete this.element.dataset[metadataKey( key )]
this.dataChanged()
}
/**
* Look up the keys for all metadata entries stored in this atom. Atoms can
* contain metadata mapping any string key to any JSONable value. This
* function returns only the keys, as an array.
*
* @returns {string[]} all keys under which metadata has been stored in this
* atom
* @see {@link module:Atoms.Atom#getMetadata getMetadata()}
* @see {@link module:Atoms.Atom#setMetadata setMetadata()}
* @see {@link module:Atoms.Atom#removeMetadata removeMetadata()}
* @see {@link module:Atoms.Atom#getHTMLMetadataKeys getHTMLMetadataKeys()}
*/
getMetadataKeys () {
return Object.keys( this.element.dataset )
.filter( isMetadataKey ).map( innerMetadataKey )
}
// For internal use by the functions below
metadataElements () {
const metadataChild = this.getChild( 'metadata', false )
return metadataChild ? Array.from( metadataChild.childNodes ).filter(
element => element.tagName == 'DIV' ) : [ ]
}
// For internal use by the functions below
findMetadataElement ( key ) {
return this.metadataElements().find( element =>
element.dataset.key == key )
}
/**
* Look up an HTML metadata entry in this atom using the given key. In
* addition to the JSONable metadata that can be stored in any atom (as
* documented in {@link module:Atoms.Atom#getMetadata getMetadata()}), you
* can also store arbitrarily large amounts of HTML in block-type atoms (not
* inline ones). This is useful, for example, when storing an entire
* dependency's HTML inside the atom that has imported it; the data can be
* stored as DOM nodes rather than as a JSON-encoded HTML string. This
* makes it easier to use when doing computations that depend on the meaning
* of the dependency's content.
*
* This type of metadata is stored inside the `"metadata"` child of the
* block-type atom, as documented {@link module:Atoms.Atom#getChild here}.
* The return value for this function will be an `HTMLDivElement` that sits
* inside that `"metadata"` child, and serves as a wrapper containing any
* number of HTML elements (or any large amount of text). While the return
* value is a single element, it is a wrapper that should be ignored,
* because its child node list is the actual value of the metadata that was
* looked up.
*
* @param {string} key - the key whose HTML should be looked up
* @returns {HTMLDivElement} the element wrapping the corresponding value
* @see {@link module:Atoms.Atom#setHTMLMetadata setHTMLMetadata()}
* @see {@link module:Atoms.Atom#removeHTMLMetadata removeHTMLMetadata()}
* @see {@link module:Atoms.Atom#getHTMLMetadataKeys getHTMLMetadataKeys()}
* @see {@link module:Atoms.Atom#getMetadata getMetadata()}
*/
getHTMLMetadata ( key ) { return this.findMetadataElement( key ) }
/**
* Store an HTML metadata entry in this atom under the given key.
* Block-type atoms can contain metadata mapping any string key to any
* amount of HTML content, as documented
* {@link module:Atoms.Atom#getHTMLMetadata here}. This function stores a
* new entry in that metadata storage area.
*
* Note that only block-type atoms can contain HTML metadata, and so calling
* this function on an inline atom will throw an error.
*
* Any HTML element passed as the value will not be used directly, but will
* be copied (i.e., its HTML code will be written to the `.innerHTML`
* property of the appropriate metadata element), in case the element in
* question is not from the same document. The value may instead be the
* HTML code itself, as a string.
*
* @param {string} key - the key under which to store the value
* @param {HTMLElement|string} value - the value to store, either as an
* HTMLElement or a string
* @see {@link module:Atoms.Atom#getHTMLMetadata getHTMLMetadata()}
* @see {@link module:Atoms.Atom#removeHTMLMetadata removeHTMLMetadata()}
* @see {@link module:Atoms.Atom#getHTMLMetadataKeys getHTMLMetadataKeys()}
* @see {@link module:Atoms.Atom#setMetadata setMetadata()}
*/
setHTMLMetadata ( key, value ) {
if ( this.element.tagName != 'DIV' )
throw new Error( 'Inline atoms cannot have HTML metadata' )
const child = this.findMetadataElement( key )
if ( child ) {
if ( !value )
child.remove()
else
child.innerHTML = removeScriptTags( value.innerHTML || value )
} else {
if ( !value ) return
const newDatum = this.element.ownerDocument.createElement( 'div' )
newDatum.dataset.key = key
newDatum.innerHTML = removeScriptTags( value.innerHTML || value )
this.getChild( 'metadata', true ).appendChild( newDatum )
}
this.dataChanged()
}
/**
* Remove an HTML metadata entry from this atom, with the given key.
*
* @param {string} key - the key that (together with its value) should be
* removed
* @see {@link module:Atoms.Atom#getHTMLMetadata getHTMLMetadata()}
* @see {@link module:Atoms.Atom#setHTMLMetadata setHTMLMetadata()}
* @see {@link module:Atoms.Atom#getHTMLMetadataKeys getHTMLMetadataKeys()}
* @see {@link module:Atoms.Atom#removeMetadata removeMetadata()}
*/
removeHTMLMetadata ( key ) {
this.findMetadataElement( key )?.remove()
this.dataChanged()
}
/**
* Look up the keys for all HTML metadata entries stored in this atom. For
* information on the difference between basic atom metadata and HTML
* metadata, see the documentation {@link module:Atoms.Atom#getHTMLMetadata
* here}.
*
* @returns {string[]} all keys under which HTML metadata has been stored in
* this atom
* @see {@link module:Atoms.Atom#getHTMLMetadata getHTMLMetadata()}
* @see {@link module:Atoms.Atom#setHTMLMetadata setHTMLMetadata()}
* @see {@link module:Atoms.Atom#removeHTMLMetadata removeHTMLMetadata()}
* @see {@link module:Atoms.Atom#getMetadataKeys getMetadataKeys()}
*/
getHTMLMetadataKeys () {
return this.metadataElements().map( element => element.dataset.key )
}
/**
* This is a placeholder implementation of this method, to be sure that all
* Atom instances have one. In subclasses, the function should pop up an
* editor for the user to edit this atom, and return a promise that resolves
* to true if the user saves their edits, and false if they cancel. The
* user's edits should already be saved into the atom when the promise
* resolves to true. This placeholder implementation returns a promise that
* false immediately, as if the user canceled their edits instantaneously.
*
* @returns {Promise} a promise that resolves to `false`
*/
edit () {
return Promise.resolve( false )
}
/**
* Atoms are always editable, unless they sit inside some DOM node that is
* marked as `contenteditable=false`. This function checks to see if that
* is the case, and returns true iff no ancestor DOM node has that property.
*/
isEditable () {
for ( let walk = this.element?.parentNode ; walk ; walk = walk.parentNode )
if ( walk.getAttribute?.( 'contenteditable' ) == 'false' )
return false
return true
}
/**
* The standard way to insert a new atom into the editor is to create it off
* screen, open up an editing dialog for that atom, and then if the user
* saves their edits, insert the new atom into the document, in the final
* state that represents the user's edits. If, however, the user cancels
* their edit of the atom, don't insert anything into the document.
*
* This function does exactly that, when called on an offscreen atom,
* passing the editor into which to insert the atom as the first parameter.
*
* @see {@link module:Atoms.Atom.edit edit()}
* @see {@link module:Shells.Shell.editThenInsert editThenInsert()}
*/
editThenInsert () {
// The following line marks where this atom will/ eventually go in the
// document, in case the editor is contingent upon the location of the
// atom with respect to earlier definitions.
this.futureLocation = this.editor.selection.getNode()
// Now do the edit:
this.edit().then( userSaved => {
if ( userSaved ) {
this.editor.insertContent( this.getHTML() )
this.dataChanged()
}
} ).finally( () => {
// And clean up the data we stored earlier; no longer needed.
delete this.futureLocation
} )
}
/**
* Set the suffix of the atom to reflect its validation result.
*
* The first argument must be one of `"valid"`, `"invalid"`, `"error"`, or
* `"indeterminate"`. No error is thrown if you use another value, but it
* will not display any meaningful feedback icon. To clear all validation
* feedback, omit both arguments, or call `setValidationResult(null)`. The
* meanings of these indicators are as follows.
*
* * `"valid"`: the atom represents correct mathematical work
* * `"invalid"`: the atom represents incorrect mathematical work
* * `"indeterminate"`: the atom cannot be clearly classified as valid or
* invalid (e.g., the user has provided something vague, and their
* settings specify that such things should not be aggressively marked
* wrong, but just indeterminate, to request more specificity)
* * `"error"`: the software encountered an internal error while attempting
* to validate the atom. (Obviously this is to be avoided! It is
* available to use if an internal error occurs, so that the user gets
* truthful feedback in such a case, and can report the bug so that we
* can set about trying to fix it. But of course we strive to write
* software does not encounter errors, and thus in which this type of
* feedback is never actually seen by a user.)
*
* The second argument should be the text to be shown when the user hovers
* their mouse over the atom. You can omit this if you do not want any
* such text, but it is recommended to always have such text, for the user's
* benefit. Note that browsers do not support HTML tags in such hover text
* panels, but they do support newline characters (`"\n"`).
*
* @param {string?} result - the validation result, one of `"valid"`,
* `"invalid"`, `"error"`, or `"indeterminate"`; can be omitted in order
* to clear all validation feedback
* @param {string?} reason - the reason for the validation result, as text to
* be displayed when the user hovers their mouse over the atom
* @see {@link module:Shells.Shell#setValidationResult setValidationResult()}
* @see {@link module:Atoms.Atom#applyValidationMessage applyValidationMessage()}
*/
setValidationResult ( result, reason ) {
if ( !result ) {
this.removeChild( 'suffix' )
this.setHoverText( null )
} else {
// if it already has a result, just add the relevant class to the
// existing classlist so we can give more nuanced feedback
const suffix = this.getChild( 'suffix' )
.querySelector( '[class^=feedback-marker]' )
if ( suffix ) {
suffix.classList.add( `feedback-marker-${result}` )
// TODO: a quick fix for now, but we should make this better
// when we upgrade the entire transitive chains feature. For
// now, just remove the hover text in this situation since it
// can be misleading.
this.setHoverText( '' )
} else {
this.fillChild( 'suffix',
`<span class="feedback-marker-${result}"> </span>` )
this.setHoverText( reason )
}
}
}
/**
* Return the array of all validation results in this Atom.
*
* @see {@link module:Atoms.Atom#setValidationResult setValidationResult()}
*/
getValidationResults () {
// inline atoms use a suffix span to store validation feedback while
// shells use an attribute, so we get both; start with the stuffix:
const suffix = this.getChild( 'suffix', false ) // false == don't create
const result = Array.from(
suffix ? suffix.querySelectorAll( '[class^=feedback-marker]' ) : [ ]
).map( s =>
Array.from( s.classList )
.filter( x => x.startsWith( 'feedback-marker' ) )
).flat().map( x => x.slice( 16 ) ) // remove prefixes
// now check if there is an attribute (as used by shells):
const attr = this.element.dataset['validation_result']
// join those two types of results (though we expect at most one):
if ( attr ) result.push( attr )
// return the array of results
return result
}
/**
* This function inspects the given {@link Message}, which the caller wants
* applied to this Atom. It determines which of its feedback contents, if
* any, should be displayed to the user. It then makes a call to
* {@link module:Atoms.Atom#setValidationResult setValidationResult()} to
* display that feedback.
*
* The default implementation is to just extract the first piece of feedback
* from the message *other than a message about an undeclared variable* and
* use it, or if there is no such feedback in the message, erase the
* feedback shown on this Atom. However, subclasses can override this
* default behavior if they have specific types of feedback that they want
* to prioritize, or they need to combine multiple types of feedback.
*
* @param {Message} message - the message whose validation data should be
* used to decorate this Atom
* @see {@link module:Atoms.Atom#setValidationResult setValidationResult()}
*/
applyValidationMessage ( message ) {
const possibilities = message.getAllFeedback()
// If it has only an undeclared variable error don't change the validation
// result as there might have been a prior result that we need to keep.
if ( possibilities.length == 1 &&
possibilities[0].code == 'undeclared variable' ) return
// Drop scoping errors about undeclared variables
const applicable = possibilities.filter(
item => item.code != 'undeclared variable' )
// Apply first remaining result
this.setValidationResult( applicable[0]?.result, applicable[0]?.reason )
}
/**
* One can construct an instance of the Atom class to interface with an
* element in the editor only if that element actually represents an atom,
* as defined in {@link module:Atoms the documentation for the Atoms
* module}. This function checks to see if the element in question does.
* To create elements that do represent atoms, see
* {@link module:Atoms.Atom.createElement createElement()}.
*
* @param {HTMLElement} element - the element to check
* @returns {boolean} whether the element represents an atom
* @see {@link module:Atoms.Atom.createElement createElement()}
* @see {@link module:Atoms.Atom.findAbove findAbove()}
*/
static isAtomElement ( element ) {
return ( element.tagName == 'DIV' || element.tagName == 'SPAN' )
&& element.classList.contains( className )
}
/**
* When a mouse event takes place in the document, it is useful to be able
* to check whether it happened inside an element representing an atom. So
* this function can take any DOM node and walk up its ancestor chain and
* find whether any element in that chain represents an atom. If so, it
* returns the corresponding Atom instance. If not, it returns null.
*
* @param {Node} node - the DOM node from which to begin searching
* @param {tinymce.Editor} editor - the editor in which the node sits
* @returns {Atom?} the nearest Atom enclosing the given `node`
* @see {@link module:Atoms.Atom.isAtomElement isAtomElement()}
*/
static findAbove ( node, editor ) {
for ( let walk = node ; walk ; walk = walk.parentNode )
if ( Atom.isAtomElement( walk ) )
return Atom.from( walk, editor )
return null
}
/**
* If this atom is contained inside another atom, then the innermost such
* atom is this one's "parent." This function returns that parent, if any,
* and null otherwise.
*
* @returns {Atom?} the Atom that is the parent of this Atom, if any
*/
parent () {
if ( !this.element.parentNode ) return null
return Atom.findAbove( this.element.parentNode, this.editor )
}
/**
* Create an HTMLElement that can be placed into the given `editor` and that
* represents an inline or block-type atom, as specified by the second
* parameter. The element will be given an HTML/CSS class that marks it as
* representing an atom, and will be marked uneditable so that TinyMCE will
* not allow the user to alter it directly.
*
* @param {tinymce.Editor} editor - the TinyMCE editor in which to create
* the element
* @param {string} tagName - the tag to use, `"span"` for inline atoms or
* `"div"` for block-type atoms (which is the default)
* @returns {HTMLElement} the element constructed
* @see {@link module:Atoms.Atom.create create()}
* @see {@link module:Atoms.Atom.newInline newInline()}
* @see {@link module:Atoms.Atom.newBlock newBlock()}
*/
static createElement ( editor, tagName = 'div' ) {
if ( tagName.toLowerCase() != 'div' && tagName.toLowerCase() != 'span' )
throw new Error( 'Invalid tag name for atom element: ' + tagName )
const result = editor.contentDocument.createElement( tagName )
result.classList.add( className )
result.setAttribute( 'contenteditable', false )
return result
}
/**
* Create a new atom element, as in {@link module:Atoms.Atom.createElement
* createElement()}, then fill its body with the given content, optionally
* set one or more metadata key-value pairs, and return an Atom instance
* corresponding to the new element. Note that this does not insert the
* element anywhere into the editor.
*
* @param {tinymce.Editor} editor - the TinyMCE editor in which to create
* the element
* @param {string} tagName - the tag to use, `"span"` for inline atoms or
* `"div"` for block-type atoms (which is the default)
* @param {string} content - the HTML code to use for filling the body of
* the newly created atom
* @param {Object} metadata - a dictionary of key-value pairs to store in
* the metadata of the newly created atom (defaults to the empty object
* `{ }`, meaning not to add any metadata)
* @returns {Atom} the Atom instance corresponding to the newly created
* atom element
* @see {@link module:Atoms.Atom.createElement createElement()}
* @see {@link module:Atoms.Atom.newInline newInline()}
* @see {@link module:Atoms.Atom.newBlock newBlock()}
*/
static create ( editor, tagName, content, metadata = { } ) {
// Create a plain atom so we can manipulate it
const result = new Atom( Atom.createElement( editor, tagName ), editor )
result.fillChild( 'body', content )
Object.keys( metadata ).forEach( key =>
result.setMetadata( key, metadata[key] ) )
// Now in case its type changed, recreate with the right subclass
return Atom.from( result.element, editor )
}
/**
* A convenience function that calls {@link module:Atoms.Atom.create
* create()} for you, but uses the tag `"span"` to make an inline element.
* This makes the client's code more clear, because it specifies that we are
* creating a new *inline* atom.
*
* @param {tinymce.Editor} editor - same as in
* {@link module:Atoms.Atom.create create()}
* @param {string} content - same as in
* {@link module:Atoms.Atom.create create()}
* @param {Object} metadata - same as in
* {@link module:Atoms.Atom.create create()}
* @returns {Atom} same as in {@link module:Atoms.Atom.create create()}
* @see {@link module:Atoms.Atom.create create()}
* @see {@link module:Atoms.Atom.createElement createElement()}
* @see {@link module:Atoms.Atom.newBlock newBlock()}
*/
static newInline ( editor, content, metadata = { } ) {
return Atom.create( editor, 'span', content, metadata )
}
/**
* A convenience function that calls {@link module:Atoms.Atom.create
* create()} for you, but uses the tag `"div"` to make an inline element.
* This makes the client's code more clear, because it specifies that we are
* creating a new *block-type* atom.
*
* @param {tinymce.Editor} editor - same as in
* {@link module:Atoms.Atom.create create()}
* @param {string} content - same as in
* {@link module:Atoms.Atom.create create()}
* @param {Object} metadata - same as in
* {@link module:Atoms.Atom.create create()}
* @returns {Atom} same as in {@link module:Atoms.Atom.create create()}
* @see {@link module:Atoms.Atom.create create()}
* @see {@link module:Atoms.Atom.createElement createElement()}
* @see {@link module:Atoms.Atom.newInline newInline()}
*/
static newBlock ( editor, content, metadata = { } ) {
return Atom.create( editor, 'div', content, metadata )
}
/**
* Find all elements in the given TinyMCE editor that represent atoms, and
* return each one in the order they appear in the document.
*
* @param {tinymce.Editor} editor - the editor in which to search
* @returns {HTMLElement[]} the array of atom elements in the editor's document
* @see {@link module:Atoms.Atom.allIn allIn()}
*/
static allElementsIn ( editor ) {
return Array.from( editor.dom.doc.querySelectorAll( `.${className}` ) )
.filter( isOnScreen )
.filter( element => editor.dom.doc.body.contains( element ) )
}
/**
* Find all elements in the given TinyMCE editor that represent atoms, and
* return each one, transformed into an instance of the Atom class. They
* are returned in the order they appear in the document.
*
* @param {tinymce.Editor} editor - the editor in which to search
* @returns {Atom[]} the array of atom in the editor's document
* @see {@link module:Atoms.Atom.allElementsIn allElementsIn()}
*/
static allIn ( editor ) {
return Atom.allElementsIn( editor ).map( element =>
Atom.from( element, editor ) )
}
/**
* Instead of the Atom constructor, use this function to convert an element
* in the document into a functioning Atom instance. The reason you should
* use this function is because the Atom constructor always creates an Atom
* instance, but this function may create an instance of an Atom subclass,
* if that's what the element represents. Thus the resulting object will
* have more specialized functionality suitable to the type of atom in
* question. This behavior is powered by the registration of Atom
* subclasses using {@link module:Atoms.Atom.registerSubclass
* registerSubclass()}.
*
* @param {HTMLElement} element - an element that has passed the check in
* {@link module:Atoms.Atom.isAtomElement isAtomElement()}
* @param {tinymce.Editor} editor - the editor in which the element sits
* @returns {Atom} the atom represented by the element
*/
static from ( element, editor ) {
const className = element.dataset['metadata_type']
const classObject = className ?
Atom.subclasses.get( JSON.parse( className ) ) : Atom
if ( !classObject )
throw new Error( 'Unknown atom type: ' + className )
return new classObject( element, editor )
}
/**
* This is a placeholder implementation of this method, to be sure that all
* Atom instances have one. In subclasses, the function should return an
* array of LogicConcepts that represent the meaning of this atom. If the
* atom has no meaning in terms of LogicConcepts, simply return an empty
* array. That is the default implementation.
*
* @returns {LogicConcept[]} an empty array of LogicConcepts, in this
* default implementation
*/
toLCs () { return [ ] }
/**
* This is a placeholder implementation of this method, to be sure that all
* Atom instances have one. In subclasses, the function should update the
* visible representation of the atom in the document based on the attributes
* stored in its metadata. The default implementation does nothing.
*/
update () { }
/**
* The default context menu for an atom is just the context menu for its
* parent atom, if any, or the empty array otherwise. This makes it easy to
* include the context menu for all ancestor atoms in any atom, by just
* starting with the context menu of the superclass and then adding items.
*
* @param {Atom} forThis - the atom that received the right-click action that
* led to the creation of the context menu
*/
contextMenu ( forThis ) {
return this.parent()?.contextMenu( forThis ) || [ ]
}
/**
* When embedding a copy of the Lurch app in a larger page, users will want
* to write simple HTML describing a Lurch document, then have a script
* create a copy of the Lurch app and put that document into it. This
* function can convert any HTML, including HTML that has atom elements in
* it, into that simplified HTML that is more human-readable, yet still
* describes a Lurch document.
*
* @returns {string} the representation of the atom as a `lurch` element
*/
static simplifiedHTML ( node ) {
const editor = editorForNode( node )
if ( Atom.isAtomElement( node ) ) {
const atom = Atom.from( node, editor )
return atom.toEmbed()
}
if ( !node.outerHTML ) return node.textContent
if ( node.childNodes.length == 0 ) return node.outerHTML
const copy = node.cloneNode( true )
copy.innerHTML = ''
const bothTags = copy.outerHTML
const startOfClose = bothTags.indexOf( '><' ) + 1
const openTag = bothTags.substring( 0, startOfClose )
const closeTag = bothTags.substring( startOfClose )
return openTag
+ Array.from( node.childNodes ).map(
child => Atom.simplifiedHTML( child ) ).join( '' )
+ closeTag
}
/**
* Traverse a DOM tree and convert any simplified HTML elements in it into
* HTML elements that represent atoms (or shells). For example, an HTML
* element of the form `<latex>...</latex>` will be replaced with the full
* HTML code for a {@link ExpositorMath} atom as it sits in a Lurch
* application's document.
*
* @param {Node} node - the DOM node to use as the root of the traversal;
* it is modified in-place
* @param {tinymce.Editor} editor - the editor in which the modified DOM
* will eventually be placed or copied
*/
static unsimplifyDOM ( node, editor ) {
// base case 1: no tag to handle, no children to recur on
if ( node.childNodes.length == 0 || !node.tagName ) return
// base case 2: expository math atom
if ( node.tagName == 'LATEX' ) {
const atom = Atom.newInline( editor, '', {
type : 'expositorymath',
latex : node.textContent
} )
atom.update()
node.replaceWith( atom.element )
}
// base case 3: expression math atom
if ( node.tagName == 'LURCH' ) {
const atom = Atom.newInline( editor, '', {
type : 'expression',
lurchNotation : node.textContent
} )
atom.update()
node.replaceWith( atom.element )
}
// recursive case 1: tag indicates a proper subclass of Shell
const tag = node.tagName.toLowerCase()
if ( Atom.subclasses.has( tag ) ) {
const subclass = Atom.subclasses.get( tag )
const shellClass = Atom.subclasses.get( 'shell' )
if ( subclass != shellClass
&& ( subclass.prototype instanceof shellClass ) ) {
const shell = Atom.from( shellClass.createElement( editor, tag ) )
// if the content we'll add is block-type, then delete all
// existing content (including the default <p> element) and add it
if ( Array.from( node.childNodes ).some( child =>
tinymce.html.Schema().getBlockElements()[child.tagName] ) ) {
shell.element.innerHTML = ''
while ( node.firstChild )
shell.element.appendChild( node.firstChild )
// if we're adding another kind of content, just put it inside
// the pre-existing <p> element inside the shell
} else if ( node.firstChild ) {
shell.element.firstChild.innerHTML = ''
while ( node.firstChild )
shell.element.firstChild.appendChild( node.firstChild )
} // Otherwise, leave the default <p> element alone
Array.from( shell.element.childNodes ).forEach(
child => Atom.unsimplifyDOM( child, editor ) )
node.replaceWith( shell.element )
}
}
// recursive case 2: no special tag; just recur on children
Array.from( node.childNodes ).forEach(
child => Atom.unsimplifyDOM( child, editor ) )
}
/**
* 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. The default implementation, defined here in the Atom
* class, is to return a string of the form `"(Lurch X)"`, where `X` is the
* type of the atom. For example, `"(Lurch expression)"`. Subclasses are
* likely to override this with something more suitable and useful in a
* LaTeX document.
*
* @returns {string} default LaTeX representation of the atom
*/
toLatex () { return `(Lurch ${this.getMetadata( 'type' )})` }
}
/**
* This function should be called in the editor's setup routine. It installs a
* single mouse event handler into the editor that can watch for click events to
* any atom, and route control flow to the event handler for that atom's type.
* It also installs a keydown event handler that watches for the Enter key
* being pressed on an atom, and routes control flow to the event handler for
* that atom's type.
*
* Second, it installs an event handler that calls every atom's update handler
* whenever the editor's content changes. This could be slow if there are many
* atoms in the document, and therefore we use the
* {@link module:Utilities.forEachWithTimeout forEachWithTimeout()} function to
* let these handlers run only when the UI is idle. We have found this to be
* necessary, because some atoms (especially those whose representation is
* generated from MathLive) are not always typeset correctly the first time that
* their HTML is placed into the atom. For some reason, the stylesheet does not
* seem to apply correctly until the *second* insertion of the typeset HTML.
*
* Finally, it installs a context menu function that may create a custom context
* menu if the user right-clicks on an atom in the document.
*
* @param {tinymce.Editor} editor - the editor in which to install the event
* handlers
* @function
*/
export const install = editor => {
// Expose this class publicly through the editor, for use in debugging at
// the console, and for use in the CLI through Puppeteer.
editor.Atom = Atom
// Install click handler to edit the atom that was clicked
editor.on( 'init', () =>
editor.dom.doc.body.addEventListener( 'click', event =>
setTimeout( () => {
const toEdit = Atom.findAbove( event.target, editor )
if ( toEdit?.isEditable() ) toEdit.edit()
} ) ) )
// Install Enter key handler for same purpose
editor.on( 'keydown', event => {
if ( event.key != 'Enter' || event.shiftKey || event.ctrlKey || event.metaKey )
return
const selected = editor.selection.getNode()
if ( Atom.isAtomElement( selected ) )
setTimeout( () => {
const toEdit = Atom.from( selected, editor )
if ( toEdit?.isEditable() ) toEdit.edit()
} )
} )
// Whenever anything changes, check to see which atoms appeared and which
// disappeared. New ones need to have their appearance updated, and both
// new and deleted ones should trigger a clearing of validation feedback.
let lastAtomElementList = [ ]
editor.on( 'input NodeChange Paste Change Undo Redo', () => {
// New ones need updating and trigger validation clearing, but we can't
// do that unless MathLive has been loaded, so first ensure that:
getConverter().then( () => {
const thisAtomElementList = Atom.allElementsIn( editor )
// Record which atoms were deleted and which changed but stayed
const atomsThatWereDeleted = lastAtomElementList.filter(
element => !thisAtomElementList.includes( element )
).map( element => Atom.from( element, editor ) )
const atomsThatChanged = thisAtomElementList.filter(
element => !lastAtomElementList.includes( element )
).map( element => Atom.from( element, editor ) )
// Those that stayed should be updated, and then only after that
// full (asynchronous) task has completed do we emit the various
// signals for changed/deleted atoms that the validation module
// might be listening for, to clear validation feedback
atomsThatChanged.forEachWithTimeout(
atom => {
try {
atom.update()
} catch ( e ) {
console.log( 'Error when updating atom', atom )
console.log( e )
}
}
).then( () => {
// Now we can notify people which atoms changed, or were deleted.
// We do this here, not at any earlier point, so that the message
// is sent once, not multiple times.
atomsThatChanged.forEach( atom => atom.dataChanged() )
atomsThatWereDeleted.forEach( atom => atom.wasDeleted() )
// We then also indicate that atom updating has finished.
editor.dispatch( 'atomUpdateFinished' )
} )
lastAtomElementList = thisAtomElementList
} )
} )
// Custom context menu creator
editor.ui.registry.addContextMenu( 'atoms', {
update : element => {
const atom = Atom.findAbove( element, editor )
const items = atom ? atom.contextMenu( atom ) : [ ]
items.forEach( item => {
const original = item.onAction
item.onAction = () => {
original()
editor.focus()
}
} )
return items
}
} )
// TinyMCE does not show cursor selection well on atoms.
// The following code tries to rectify that by putting a special class on
// atoms that are within the current cursor selection, so that they can be
// styled in a way that makes it clear that they are selected.
editor.on( 'SelectionChange', () => {
const range = editor.selection.getRng()
const nodeIsSelected = node => {
const nodeRange = document.createRange()
nodeRange.selectNode( node )
return range.compareBoundaryPoints( Range.START_TO_START, nodeRange ) < 1
&& range.compareBoundaryPoints( Range.END_TO_END, nodeRange ) > -1
}
Atom.allElementsIn( editor ).forEach( element => {
if ( nodeIsSelected( element ) )
element.classList.add( 'atom-is-selected' )
else
element.classList.remove( 'atom-is-selected' )
} )
} )
}
export default { className, Atom, install }
source