import Parser from './parser';
import Util from './util';
import StringManager from './stringmanager';
import ContentManager from './contentmanager';
import Latex from './latex';
import MathML from './mathml';
import CustomEditors from './customeditors';
import Configuration from './configuration';
import jsProperties from './jsvariables';
import Event from './event';
import Listeners from './listeners';
import Image from './image';
import ServiceProvider from './serviceprovider';
import ModalDialog from './modal';
import './polyfills';
import '../styles/styles.css';
/**
* @typedef {Object} CoreProperties
* @property {ServiceProviderProperties} serviceProviderProperties
* - The ServiceProvider class properties. *
*/
export default class Core {
/**
* @classdesc
* This class represents MathType integration Core, managing the following:
* - Integration initialization.
* - Event managing.
* - Insertion of formulas into the edit area.
* ```js
* let core = new Core();
* core.addListener(listener);
* core.language = 'en';
*
* // Initializing Core class.
* core.init(configurationService);
* ```
* @constructs
* Core constructor.
* @param {CoreProperties}
*/
constructor(coreProperties) {
/**
* Language. Needed for accessibility and locales. 'en' by default.
* @type {String}
*/
this.language = 'en';
/**
* Edit mode, 'images' by default. Admits the following values:
* - images
* - latex
* @type {String}
*/
this.editMode = 'images';
/**
* Modal dialog instance.
* @type {ModalDialog}
*/
this.modalDialog = null;
/**
* The instance of {@link CustomEditors}. By default
* the only custom editor is the Chemistry editor.
* @type {CustomEditors}
*/
this.customEditors = new CustomEditors();
/**
* Chemistry editor.
* @type {CustomEditor}
*/
const chemEditorParams = {
name: 'Chemistry',
toolbar: 'chemistry',
icon: 'chem.png',
confVariable: 'chemEnabled',
title: 'ChemType',
tooltip: 'Insert a chemistry formula - ChemType', // TODO: Localize tooltip.
};
this.customEditors.addEditor('chemistry', chemEditorParams);
/**
* Environment properties. This object contains data about the integration platform.
* @typedef IntegrationEnvironment
* @property {String} IntegrationEnvironment.editor - Editor name. For example the HTML editor.
* @property {String} IntegrationEnvironment.mode - Integration save mode.
* @property {String} IntegrationEnvironment.version - Integration version.
*
*/
/**
* The environment properties object.
* @type {IntegrationEnvironment}
*/
this.environment = {};
/**
* @typedef EditionProperties
* @property {Boolean} editionProperties.isNewElement - True if the formula is a new one.
* False otherwise.
* @property {HTMLImageElement} editionProperties.temporalImage- The image element.
* Null if the formula is new.
* @property {Range} editionProperties.latexRange - Tha range that contains the LaTeX formula.
* @property {Range} editionProperties.range - The range that contains the image element.
* @property {String} editionProperties.editMode - The edition mode. 'images' by default.
*/
/**
* The properties of the current edition process.
* @type {EditionProperties}
*/
this.editionProperties = {};
this.editionProperties.isNewElement = true;
this.editionProperties.temporalImage = null;
this.editionProperties.latexRange = null;
this.editionProperties.range = null;
/**
* The {@link IntegrationModel} instance.
* @type {IntegrationModel}
*/
this.integrationModel = null;
/**
* The {@link ContentManager} instance.
* @type {ContentManager}
*/
this.contentManager = null;
/**
* The current browser.
* @type {String}
*/
this.browser = (() => {
const ua = navigator.userAgent;
let browser = 'none';
if (ua.search('Edge/') >= 0) {
browser = 'EDGE';
} else if (ua.search('Chrome/') >= 0) {
browser = 'CHROME';
} else if (ua.search('Trident/') >= 0) {
browser = 'IE';
} else if (ua.search('Firefox/') >= 0) {
browser = 'FIREFOX';
} else if (ua.search('Safari/') >= 0) {
browser = 'SAFARI';
}
return browser;
}
)();
/**
* Plugin listeners.
* @type {Array.<Object>}
*/
this.listeners = new Listeners();
/**
* Service provider properties.
* @type {ServiceProviderProperties}
*/
this.serviceProviderProperties = {};
if ('serviceProviderProperties' in coreProperties) {
this.serviceProviderProperties = coreProperties.serviceProviderProperties;
} else {
throw new Error('serviceProviderProperties property missing.');
}
}
/**
* Static property.
* Core listeners.
* @private
* @type {Listeners}
*/
static get globalListeners() {
return Core._globalListeners;
}
/**
* Static property setter.
* Set core listeners.
* @param {Listeners} value - The property value.
* @ignore
*/
static set globalListeners(value) {
Core._globalListeners = value;
}
/**
* Core state. Says if it was loaded previously.
* True when Core.init was called. Otherwise, false.
* @private
* @type {Boolean}
*/
static get initialized() {
return Core._initialized;
}
/**
* Core state. Says if it was loaded previously.
* @param {Boolean} value - True to say that Core.init was called. Otherwise, false.
* @ignore
*/
static set initialized(value) {
Core._initialized = value;
}
/**
* Sets the {@link Core.integrationModel} property.
* @param {IntegrationModel} integrationModel - The {@link IntegrationModel} property.
*/
setIntegrationModel(integrationModel) {
this.integrationModel = integrationModel;
}
/**
* Sets the {@link Core.environment} property.
* @param {IntegrationEnvironment} integrationEnvironment -
* The {@link IntegrationEnvironment} object.
*/
setEnvironment(integrationEnvironment) {
if ('editor' in integrationEnvironment) {
this.environment.editor = integrationEnvironment.editor;
}
if ('mode' in integrationEnvironment) {
this.environment.mode = integrationEnvironment.mode;
}
if ('version' in integrationEnvironment) {
this.environment.version = integrationEnvironment.version;
}
}
/**
* Returns the current {@link ModalDialog} instance.
* @returns {ModalDialog} The current {@link ModalDialog} instance.
*/
getModalDialog() {
return this.modalDialog;
}
/**
* Inits the {@link Core} class, doing the following:
* - Calls asynchronously configuration service, retrieving the backend configuration in a JSON.
* - Updates {@link Configuration} class with the previous configuration properties.
* - Updates the {@link ServiceProvider} class using the configuration service path as reference.
* - Loads language strings.
* - Fires onLoad event.
* @param {Object} serviceParameters - Service parameters.
*/
init() {
if (!Core.initialized) {
const serviceProviderListener = Listeners.newListener('onInit', () => {
const jsConfiguration = ServiceProvider.getService('configurationjs', '', 'get');
const jsonConfiguration = JSON.parse(jsConfiguration);
Configuration.addConfiguration(jsonConfiguration);
// Adding JavaScript (not backend) configuration variables.
Configuration.addConfiguration(jsProperties);
// Fire 'onLoad' event:
// All integration must listen this event in order to know if the plugin
// has been properly loaded.
StringManager.language = this.language;
this.listeners.fire('onLoad', {});
});
ServiceProvider.addListener(serviceProviderListener);
ServiceProvider.init(this.serviceProviderProperties);
Core.initialized = true;
} else {
// Case when there are more than two editor instances.
// After the first editor all the other editors don't need to load any file or service.
this.listeners.fire('onLoad', {});
}
}
/**
* Adds a {@link Listener} to the current instance of the {@link Core} class.
* @param {Listener} listener - The listener object.
*/
addListener(listener) {
this.listeners.add(listener);
}
/**
* Adds the global {@link Listener} instance to {@link Core} class.
* @param {Listener} listener - The event listener to be added.
* @static
*/
static addGlobalListener(listener) {
Core.globalListeners.add(listener);
}
beforeUpdateFormula(mathml, wirisProperties) {
/**
* This event is fired before updating the formula.
* @type {Object}
* @property {String} mathml - MathML to be transformed.
* @property {String} editMode - Edit mode.
* @property {Object} wirisProperties - Extra attributes for the formula.
* @property {String} language - Formula language.
*/
const beforeUpdateEvent = new Event();
beforeUpdateEvent.mathml = mathml;
// Cloning wirisProperties object
// We don't want wirisProperties object modified.
beforeUpdateEvent.wirisProperties = {};
if (wirisProperties != null) {
Object.keys(wirisProperties).forEach((attr) => {
beforeUpdateEvent.wirisProperties[attr] = wirisProperties[attr];
});
}
// Read only.
beforeUpdateEvent.language = this.language;
beforeUpdateEvent.editMode = this.editMode;
if (this.listeners.fire('onBeforeFormulaInsertion', beforeUpdateEvent)) {
return {};
}
if (Core.globalListeners.fire('onBeforeFormulaInsertion', beforeUpdateEvent)) {
return {};
}
return {
mathml: beforeUpdateEvent.mathml,
wirisProperties: beforeUpdateEvent.wirisProperties,
};
}
/**
* Converts a MathML into it's correspondent image and inserts the image is
* inserted in a HTMLElement target by creating
* a new image or updating an existing one.
* @param {HTMLElement} focusElement - The HTMLElement to be focused after the insertion.
* @param {Window} windowTarget - The window element where the editable content is.
* @param {String} mathml - The MathML.
* @param {Array.<Object>} wirisProperties - The extra attributes for the formula.
* @returns {ReturnObject} - Object with the information of the node or latex to insert.
*/
insertFormula(focusElement, windowTarget, mathml, wirisProperties) {
/**
* It is the object with the information of the node or latex to insert.
* @typedef ReturnObject
* @property {Node} [node] - The DOM node to insert.
* @property {String} [latex] - The latex to insert.
*/
const returnObject = {};
if (!mathml) {
this.insertElementOnSelection(null, focusElement, windowTarget);
} else if (this.editMode === 'latex') {
returnObject.latex = Latex.getLatexFromMathML(mathml);
// this.integrationModel.getNonLatexNode is an integration wrapper
// to have special behaviours for nonLatex.
// Not all the integrations have special behaviours for nonLatex.
if (!!this.integrationModel.fillNonLatexNode && !returnObject.latex) {
const afterUpdateEvent = new Event();
afterUpdateEvent.editMode = this.editMode;
afterUpdateEvent.windowTarget = windowTarget;
afterUpdateEvent.focusElement = focusElement;
afterUpdateEvent.latex = returnObject.latex;
this.integrationModel.fillNonLatexNode(afterUpdateEvent, windowTarget, mathml);
} else {
returnObject.node = windowTarget.document.createTextNode(`$$${returnObject.latex}$$`);
}
this.insertElementOnSelection(returnObject.node, focusElement, windowTarget);
} else {
returnObject.node = Parser.mathmlToImgObject(windowTarget.document,
mathml,
wirisProperties, this.language);
this.insertElementOnSelection(returnObject.node, focusElement, windowTarget);
}
return returnObject;
}
afterUpdateFormula(focusElement, windowTarget, node, latex) {
/**
* This event is fired after update the formula.
* @type {Event}
* @param {String} editMode - edit mode.
* @param {Object} windowTarget - target window.
* @param {Object} focusElement - target element to be focused after update.
* @param {String} latex - LaTeX generated by the formula (editMode=latex).
* @param {Object} node - node generated after update the formula (text if LaTeX img otherwise).
*/
const afterUpdateEvent = new Event();
afterUpdateEvent.editMode = this.editMode;
afterUpdateEvent.windowTarget = windowTarget;
afterUpdateEvent.focusElement = focusElement;
afterUpdateEvent.node = node;
afterUpdateEvent.latex = latex;
if (this.listeners.fire('onAfterFormulaInsertion', afterUpdateEvent)) {
return {};
}
if (Core.globalListeners.fire('onAfterFormulaInsertion', afterUpdateEvent)) {
return {};
}
return {};
}
/**
* Sets the caret after a given Node and set the focus to the owner document.
* @param {Node} node - The Node element.
*/
placeCaretAfterNode(node) {
this.integrationModel.getSelection();
const nodeDocument = node.ownerDocument;
if (typeof nodeDocument.getSelection !== 'undefined' && !!node.parentElement) {
const range = nodeDocument.createRange();
range.setStartAfter(node);
range.collapse(true);
const selection = nodeDocument.getSelection();
selection.removeAllRanges();
selection.addRange(range);
nodeDocument.body.focus();
}
}
/**
* Replaces a Selection object with an HTMLElement.
* @param {HTMLElement} element - The HTMLElement to replace the selection.
* @param {HTMLElement} focusElement - The HTMLElement to be focused after the replace.
* @param {Window} windowTarget - The window target.
*/
insertElementOnSelection(element, focusElement, windowTarget) {
if (this.editionProperties.isNewElement) {
if (element) {
if (focusElement.type === 'textarea') {
Util.updateTextArea(focusElement, element.textContent);
} else if (document.selection && document.getSelection === 0) {
let range = windowTarget.document.selection.createRange();
windowTarget.document.execCommand('InsertImage', false, element.src);
if (!('parentElement' in range)) {
windowTarget.document.execCommand('delete', false);
range = windowTarget.document.selection.createRange();
windowTarget.document.execCommand('InsertImage', false, element.src);
}
if ('parentElement' in range) {
const temporalObject = range.parentElement();
if (temporalObject.nodeName.toUpperCase() === 'IMG') {
temporalObject.parentNode.replaceChild(element, temporalObject);
} else {
// IE9 fix: parentNode() does not return the IMG node,
// returns the parent DIV node. In IE < 9, pasteHTML does not work well.
range.pasteHTML(Util.createObjectCode(element));
}
}
} else {
const editorSelection = this.integrationModel.getSelection();
let range = null;
// In IE is needed keep the range due to after focus the modal window
// it can't be retrieved the last selection.
if (this.editionProperties.range) {
({ range } = this.editionProperties);
this.editionProperties.range = null;
} else {
range = editorSelection.getRangeAt(0);
}
// Delete if something was surrounded.
range.deleteContents();
let node = range.startContainer;
const position = range.startOffset;
if (node.nodeType === 3) { // TEXT_NODE.
node = node.splitText(position);
node.parentNode.insertBefore(element, node);
} else if (node.nodeType === 1) { // ELEMENT_NODE.
node.insertBefore(element, node.childNodes[position]);
}
this.placeCaretAfterNode(element);
}
} else if (focusElement.type === 'textarea') {
focusElement.focus();
} else {
const editorSelection = this.integrationModel.getSelection();
editorSelection.removeAllRanges();
if (this.editionProperties.range) {
const { range } = this.editionProperties;
this.editionProperties.range = null;
editorSelection.addRange(range);
}
}
} else if (this.editionProperties.latexRange) {
if (document.selection && document.getSelection === 0) {
this.editionProperties.isNewElement = true;
this.editionProperties.latexRange.select();
this.insertElementOnSelection(element, focusElement, windowTarget);
} else {
this.editionProperties.latexRange.deleteContents();
this.editionProperties.latexRange.insertNode(element);
this.placeCaretAfterNode(element);
}
} else if (focusElement.type === 'textarea') {
let item;
// Wrapper for some integrations that can have special behaviours to show latex.
if (typeof this.integrationModel.getSelectedItem !== 'undefined') {
item = this.integrationModel.getSelectedItem(focusElement, false);
} else {
item = Util.getSelectedItemOnTextarea(focusElement);
}
Util.updateExistingTextOnTextarea(focusElement,
element.textContent,
item.startPosition,
item.endPosition);
} else {
if (element && element.nodeName.toLowerCase() === 'img') { // Editor empty, formula has been erased on edit.
// There are editors (e.g: CKEditor) that put attributes in images.
// We don't allow that behaviour in our images.
Image.removeImgDataAttributes(this.editionProperties.temporalImage);
// Clone is needed to maintain event references to temporalImage.
Image.clone(element, this.editionProperties.temporalImage);
} else {
this.editionProperties.temporalImage.remove();
}
this.placeCaretAfterNode(this.editionProperties.temporalImage);
}
}
/**
* Opens a modal dialog containing MathType editor..
* @param {HTMLElement} target - The target HTMLElement where formulas should be inserted.
* @param {Boolean} isIframe - True if the target HTMLElement is an iframe. False otherwise.
*/
openModalDialog(target, isIframe) {
// Textarea elements don't have normal document ranges. It only accepts latex edit.
this.editMode = 'images';
// In IE is needed keep the range due to after focus the modal window
// it can't be retrieved the last selection.
try {
if (isIframe) {
// Is needed focus the target first.
target.contentWindow.focus();
const selection = target.contentWindow.getSelection();
this.editionProperties.range = selection.getRangeAt(0);
} else {
// Is needed focus the target first.
target.focus();
const selection = getSelection();
this.editionProperties.range = selection.getRangeAt(0);
}
} catch (e) {
this.editionProperties.range = null;
}
if (isIframe === undefined) {
isIframe = true;
}
this.editionProperties.latexRange = null;
if (target) {
let selectedItem;
if (typeof this.integrationModel.getSelectedItem !== 'undefined') {
selectedItem = this.integrationModel.getSelectedItem(target, isIframe);
} else {
selectedItem = Util.getSelectedItem(target, isIframe);
}
// Check LaTeX if and only if the node is a text node (nodeType==3).
if (selectedItem) {
// Case when image was selected and button pressed.
if (!selectedItem.caretPosition && Util.containsClass(selectedItem.node, Configuration.get('imageClassName'))) {
this.editionProperties.temporalImage = selectedItem.node;
this.editionProperties.isNewElement = false;
} else if (selectedItem.node.nodeType === 3) {
// If it's a text node means that editor is working with LaTeX.
if (this.integrationModel.getMathmlFromTextNode) {
// If integration has this function it isn't set range due to we don't
// know if it will be put into a textarea as a text or image.
const mathml = this.integrationModel.getMathmlFromTextNode(
selectedItem.node,
selectedItem.caretPosition,
);
if (mathml) {
this.editMode = 'latex';
this.editionProperties.isNewElement = false;
this.editionProperties.temporalImage = document.createElement('img');
this.editionProperties.temporalImage.setAttribute(
Configuration.get('imageMathmlAttribute'),
MathML.safeXmlEncode(mathml),
);
}
} else {
const latexResult = Latex.getLatexFromTextNode(
selectedItem.node,
selectedItem.caretPosition,
);
if (latexResult) {
const mathml = Latex.getMathMLFromLatex(latexResult.latex);
this.editMode = 'latex';
this.editionProperties.isNewElement = false;
this.editionProperties.temporalImage = document.createElement('img');
this.editionProperties.temporalImage.setAttribute(
Configuration.get('imageMathmlAttribute'),
MathML.safeXmlEncode(mathml),
);
const windowTarget = isIframe ? target.contentWindow : window;
if (target.tagName.toLowerCase() !== 'textarea') {
if (document.selection) {
let leftOffset = 0;
let previousNode = latexResult.startNode.previousSibling;
while (previousNode) {
leftOffset += Util.getNodeLength(previousNode);
previousNode = previousNode.previousSibling;
}
this.editionProperties.latexRange = windowTarget.document.selection.createRange();
this.editionProperties.latexRange.moveToElementText(
latexResult.startNode.parentNode,
);
this.editionProperties.latexRange.move(
'character',
leftOffset + latexResult.startPosition,
);
this.editionProperties.latexRange.moveEnd(
'character',
latexResult.latex.length + 4,
); // Plus 4 for the '$$' characters.
} else {
this.editionProperties.latexRange = windowTarget.document.createRange();
this.editionProperties.latexRange.setStart(
latexResult.startNode,
latexResult.startPosition,
);
this.editionProperties.latexRange.setEnd(
latexResult.endNode,
latexResult.endPosition,
);
}
}
}
}
}
} else if (target.tagName.toLowerCase() === 'textarea') {
// By default editMode is 'images', but when target is a textarea it needs to be 'latex'.
this.editMode = 'latex';
}
}
// Setting an object with the editor parameters.
// Editor parameters can be customized in several ways:
// 1 - editorAttributes: Contains the default editor attributes,
// usually the metrics in a comma separated string. Always exists.
// 2 - editorParameters: Object containing custom editor parameters.
// These parameters are defined in the backend. So they affects all integration instances.
// The backend send the default editor attributes in a coma separated
// with the following structure: key1=value1,key2=value2...
const defaultEditorAttributesArray = Configuration.get('editorAttributes').split(', ');
const defaultEditorAttributes = {};
for (let i = 0, len = defaultEditorAttributesArray.length; i < len; i += 1) {
const tempAttribute = defaultEditorAttributesArray[i].split('=');
const key = tempAttribute[0];
const value = tempAttribute[1];
defaultEditorAttributes[key] = value;
}
// Custom editor parameters.
const editorAttributes = {};
// Editor parameters in backend, usually configuration.ini.
const serverEditorParameters = Configuration.get('editorParameters');
// Editor parameters through JavaScript configuration.
const cliendEditorParameters = this.integrationModel.editorParameters;
Object.assign(editorAttributes, defaultEditorAttributes, serverEditorParameters);
Object.assign(editorAttributes, defaultEditorAttributes, cliendEditorParameters);
editorAttributes.language = this.language;
editorAttributes.rtl = this.integrationModel.rtl;
const contentManagerAttributes = {};
contentManagerAttributes.editorAttributes = editorAttributes;
contentManagerAttributes.language = this.language;
contentManagerAttributes.customEditors = this.customEditors;
contentManagerAttributes.environment = this.environment;
if (this.modalDialog == null) {
this.modalDialog = new ModalDialog(editorAttributes);
this.contentManager = new ContentManager(contentManagerAttributes);
// When an instance of ContentManager is created we need to wait until
// the ContentManager is ready by listening 'onLoad' event.
const listener = Listeners.newListener('onLoad', () => {
this.contentManager.isNewElement = this.editionProperties.isNewElement;
if (this.editionProperties.temporalImage != null) {
const mathML = MathML.safeXmlDecode(this.editionProperties.temporalImage.getAttribute(Configuration.get('imageMathmlAttribute')));
this.contentManager.mathML = mathML;
}
});
this.contentManager.addListener(listener);
this.contentManager.init();
this.modalDialog.setContentManager(this.contentManager);
this.contentManager.setModalDialogInstance(this.modalDialog);
} else {
this.contentManager.isNewElement = this.editionProperties.isNewElement;
if (this.editionProperties.temporalImage != null) {
const mathML = MathML.safeXmlDecode(this.editionProperties.temporalImage.getAttribute(Configuration.get('imageMathmlAttribute')));
this.contentManager.mathML = mathML;
}
}
this.contentManager.setIntegrationModel(this.integrationModel);
this.modalDialog.open();
}
/**
* Returns the {@link CustomEditors} instance.
* @return {CustomEditors} The current {@link CustomEditors} instance.
*/
getCustomEditors() {
return this.customEditors;
}
}
/**
* Core static listeners.
* @type {Listeners}
* @private
*/
Core._globalListeners = new Listeners();
/**
* Resources state. Says if they were loaded or not.
* @type {Boolean}
* @private
*/
Core._initialized = false;