@wiris/mathtype-html-integration-devkit
Version:
Allows to integrate MathType Web into any JavaScript HTML WYSIWYG rich text editor.
764 lines (682 loc) • 26.5 kB
JavaScript
import Configuration from "./configuration";
import Core from "./core.src";
import EditorListener from "./editorlistener";
import Listeners from "./listeners";
import MathML from "./mathml";
import Util from "./util";
import Telemeter from "./telemeter";
export default class ContentManager {
/**
* @classdesc
* This class represents a modal dialog, managing the following:
* - The insertion of content into the current instance of the {@link ModalDialog} class.
* - The actions to be done once the modal object has been submitted
* (submitAction} method).
* - The update of the content when the {@link ModalDialog} class is also updated,
* for example when ModalDialog is re-opened.
* - The communication between the {@link ModalDialog} class and itself, if the content
* has been changed (hasChanges} method).
* @constructs
* @param {Object} contentManagerAttributes - Object containing all attributes needed to
* create a new instance.
*/
constructor(contentManagerAttributes) {
/**
* An object containing MathType editor parameters. See
* http://docs.wiris.com/en/mathtype/mathtype_web/sdk-api/parameters for further information.
* @type {Object}
*/
this.editorAttributes = {};
if ("editorAttributes" in contentManagerAttributes) {
this.editorAttributes = contentManagerAttributes.editorAttributes;
} else {
throw new Error("ContentManager constructor error: editorAttributes property missed.");
}
/**
* CustomEditors instance. Contains the custom editors.
* @type {CustomEditors}
*/
this.customEditors = null;
if ("customEditors" in contentManagerAttributes) {
this.customEditors = contentManagerAttributes.customEditors;
}
/**
* Environment properties. This object contains data about the integration platform.
* @type {Object}
* @property {String} editor - Editor name. Usually the HTML editor.
* @property {String} mode - Save mode. Xml by default.
* @property {String} version - Plugin version.
*/
this.environment = {};
if ("environment" in contentManagerAttributes) {
this.environment = contentManagerAttributes.environment;
} else {
throw new Error("ContentManager constructor error: environment property missed");
}
/**
* ContentManager language.
* @type {String}
*/
this.language = "";
if ("language" in contentManagerAttributes) {
this.language = contentManagerAttributes.language;
} else {
throw new Error("ContentManager constructor error: language property missed");
}
/**
* {@link EditorListener} instance. Manages the changes inside the editor.
* @type {EditorListener}
*/
this.editorListener = new EditorListener();
/**
* MathType editor instance.
* @type {JsEditor}
*/
this.editor = null;
/**
* Navigator user agent.
* @type {String}
*/
this.ua = navigator.userAgent.toLowerCase();
/**
* Mobile device properties object
* @type {DeviceProperties}
*/
this.deviceProperties = {};
this.deviceProperties.isAndroid = this.ua.indexOf("android") > -1;
this.deviceProperties.isIOS = ContentManager.isIOS();
/**
* Custom editor toolbar.
* @type {String}
*/
this.toolbar = null;
/**
* Custom editor toolbar.
* @type {String}
*/
this.dbclick = null;
/**
* Instance of the {@link ModalDialog} class associated with the current
* {@link ContentManager} instance.
* @type {ModalDialog}
*/
this.modalDialogInstance = null;
/**
* ContentManager listeners.
* @type {Listeners}
*/
this.listeners = new Listeners();
/**
* MathML associated to the ContentManager instance.
* @type {String}
*/
this.mathML = null;
/**
* Indicates if the edited element is a new one or not.
* @type {Boolean}
*/
this.isNewElement = true;
/**
* {@link IntegrationModel} instance. Needed to call wrapper methods.
* @type {IntegrationModel}
*/
this.integrationModel = null;
}
/**
* Adds a new listener to the current {@link ContentManager} instance.
* @param {Object} listener - The listener to be added.
*/
addListener(listener) {
this.listeners.add(listener);
}
/**
* Sets an instance of {@link IntegrationModel} class to the current {@link ContentManager}
* instance.
* @param {IntegrationModel} integrationModel - The {@link IntegrationModel} instance.
*/
setIntegrationModel(integrationModel) {
this.integrationModel = integrationModel;
}
/**
* Sets the {@link ModalDialog} instance into the current {@link ContentManager} instance.
* @param {ModalDialog} modalDialogInstance - The {@link ModalDialog} instance
*/
setModalDialogInstance(modalDialogInstance) {
this.modalDialogInstance = modalDialogInstance;
}
/**
* Inserts the content into the current {@link ModalDialog} instance updating
* the title and inserting the JavaScript editor.
*/
insert() {
// Before insert the editor we update the modal object title to avoid weird render display.
this.updateTitle(this.modalDialogInstance);
this.insertEditor(this.modalDialogInstance);
}
/**
* Inserts MathType editor into the {@link ModalDialog.contentContainer}. It waits until
* editor's JavaScript is loaded.
*/
insertEditor() {
if (ContentManager.isEditorLoaded()) {
this.editor = window.com.wiris.jsEditor.JsEditor.newInstance(this.editorAttributes);
this.editor.insertInto(this.modalDialogInstance.contentContainer);
this.editor.focus();
// `editor.action("rtl");` toggles the RTL mode based on the current state, it doesn't just switch to RTL.
if (this.modalDialogInstance.rtl && !this.editor.getEditorModel().isRTL()) {
this.editor.action("rtl");
}
// Setting div in rtl in case of it's activated.
if (this.editor.getEditorModel().isRTL()) {
this.editor.element.style.direction = "rtl";
}
// Editor listener: this object manages the changes logic of editor.
this.editor.getEditorModel().addEditorListener(this.editorListener);
// iOS events.
if (this.modalDialogInstance.deviceProperties.isIOS) {
setTimeout(function () {
// Make sure the modalDialogInstance is available when the timeout is over
// to avoid throw errors and stop execution.
if (this.hasOwnProperty("modalDialogInstance")) this.modalDialogInstance.hideKeyboard(); // eslint-disable-line no-prototype-builtins
}, 400);
const formulaDisplayDiv = document.getElementsByClassName("wrs_formulaDisplay")[0];
Util.addEvent(formulaDisplayDiv, "focus", this.modalDialogInstance.handleOpenedIosSoftkeyboard);
Util.addEvent(formulaDisplayDiv, "blur", this.modalDialogInstance.handleClosedIosSoftkeyboard);
}
// Fire onLoad event. Necessary to set the MathML into the editor
// after is loaded.
this.listeners.fire("onLoad", {});
} else {
setTimeout(ContentManager.prototype.insertEditor.bind(this), 100);
}
}
/**
* Initializes the current class by loading MathType script.
*/
init() {
if (!ContentManager.isEditorLoaded()) {
this.addEditorAsExternalDependency();
}
}
/**
* Adds script element to the DOM to include editor externally.
*/
addEditorAsExternalDependency() {
const script = document.createElement("script");
script.type = "text/javascript";
let editorUrl = Configuration.get("editorUrl");
// We create an object url for parse url string and work more efficiently.
const anchorElement = document.createElement("a");
ContentManager.setHrefToAnchorElement(anchorElement, editorUrl);
ContentManager.setProtocolToAnchorElement(anchorElement);
editorUrl = ContentManager.getURLFromAnchorElement(anchorElement);
// Load editor URL. We add stats as GET params.
const stats = this.getEditorStats();
script.src = `${editorUrl}?lang=${this.language}&stats-editor=${stats.editor}&stats-mode=${stats.mode}&stats-version=${stats.version}`;
document.getElementsByTagName("head")[0].appendChild(script);
}
/**
* Sets the specified url to the anchor element.
* @param {HTMLAnchorElement} anchorElement - Element where set 'url'.
* @param {String} url - URL to set.
*/
static setHrefToAnchorElement(anchorElement, url) {
anchorElement.href = url;
}
/**
* Sets the current protocol to the anchor element.
* @param {HTMLAnchorElement} anchorElement - Element where set its protocol.
*/
static setProtocolToAnchorElement(anchorElement) {
// Change to https if necessary.
if (window.location.href.indexOf("https://") === 0) {
// It check if browser is https and configuration is http.
// If this is so, we will replace protocol.
if (anchorElement.protocol === "http:") {
anchorElement.protocol = "https:";
}
}
}
/**
* Returns the url of the anchor element adding the current port
* if it is needed.
* @param {HTMLAnchorElement} anchorElement - Element where extract the url.
* @returns {String}
*/
static getURLFromAnchorElement(anchorElement) {
// Check protocol and remove port if it's standard.
const removePort = anchorElement.port === "80" || anchorElement.port === "443" || anchorElement.port === "";
return `${anchorElement.protocol}//${anchorElement.hostname}${removePort ? "" : `:${anchorElement.port}`}${anchorElement.pathname.startsWith("/") ? anchorElement.pathname : `/${anchorElement.pathname}`}`; // eslint-disable-line max-len
}
/**
* Returns object with editor stats.
*
* @typedef {Object} EditorStatsObject
* @property {string} editor - Editor name.
* @property {string} mode - Current configuration for formula save mode.
* @property {string} version - Current plugins version.
* @returns {EditorStatsObject}
*/
getEditorStats() {
// Editor stats. Use environment property to set it.
const stats = {};
if ("editor" in this.environment) {
stats.editor = this.environment.editor;
} else {
stats.editor = "unknown";
}
if ("mode" in this.environment) {
stats.mode = this.environment.mode;
} else {
stats.mode = Configuration.get("saveMode");
}
if ("version" in this.environment) {
stats.version = this.environment.version;
} else {
stats.version = Configuration.get("version");
}
return stats;
}
/**
* Returns true if device is iOS. Otherwise, false.
* @returns {Boolean}
*/
static isIOS() {
return (
["iPad Simulator", "iPhone Simulator", "iPod Simulator", "iPad", "iPhone", "iPod"].includes(navigator.platform) ||
// iPad on iOS 13 detection
(navigator.userAgent.includes("Mac") && "ontouchend" in document)
);
}
/**
* Returns true if device is Mobile. Otherwise, false.
* @returns {Boolean}
*/
static isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
/**
* Returns true if editor is loaded. Otherwise, false.
* @returns {Boolean}
*/
static isEditorLoaded() {
// To know if editor JavaScript is loaded we need to wait until
// window.com.wiris.jsEditor.JsEditor.newInstance is ready.
return (
window.com &&
window.com.wiris &&
window.com.wiris.jsEditor &&
window.com.wiris.jsEditor.JsEditor &&
window.com.wiris.jsEditor.JsEditor.newInstance
);
}
/**
* Sets the {@link ContentManager.editor} initial content.
*/
setInitialContent() {
if (!this.isNewElement) {
this.setMathML(this.mathML);
}
}
/**
* Sets a MathML into {@link ContentManager.editor} instance.
* @param {String} mathml - MathML string.
* @param {Boolean} focusDisabled - If true editor don't get focus after the MathML is set.
* False by default.
*/
setMathML(mathml, focusDisabled) {
// By default focus is enabled.
if (typeof focusDisabled === "undefined") {
focusDisabled = false;
}
// Using setMathML method is not a change produced by the user but for the API
// so we set to false the contentChange property of editorListener.
this.editor.setMathMLWithCallback(mathml, () => {
this.editorListener.setWaitingForChanges(true);
});
// We need to wait a little until the callback finish.
setTimeout(() => {
this.editorListener.setIsContentChanged(false);
}, 500);
// In some scenarios - like closing modal object - editor mustn't be focused.
if (!focusDisabled) {
this.onFocus();
}
}
/**
* Sets the focus to the current instance of {@link ContentManager.editor}. Triggered by
* {@link ModalDialog.focus}.
*/
onFocus() {
if (typeof this.editor !== "undefined" && this.editor != null) {
this.editor.focus();
// On WordPress integration, the focus gets lost right after setting it.
// To fix this, we enforce another focus some milliseconds after this behaviour.
setTimeout(() => {
this.editor.focus();
}, 100);
}
}
/**
* Updates the edition area by calling {@link IntegrationModel.updateFormula}.
* Triggered by {@link ModalDialog.submitAction}.
*/
submitAction() {
if (!this.editor.isFormulaEmpty()) {
let mathML = this.editor.getMathMLWithSemantics();
// Add class for custom editors.
if (this.customEditors.getActiveEditor() !== null) {
const { toolbar } = this.customEditors.getActiveEditor();
mathML = MathML.addCustomEditorClassAttribute(mathML, toolbar);
} else {
// We need - if exists - the editor name from MathML
// class attribute.
Object.keys(this.customEditors.editors).forEach((key) => {
mathML = MathML.removeCustomEditorClassAttribute(mathML, key);
});
}
const mathmlEntitiesEncoded = MathML.mathMLEntities(mathML);
this.integrationModel.updateFormula(mathmlEntitiesEncoded);
} else {
this.integrationModel.updateFormula(null);
}
this.customEditors.disable();
this.integrationModel.notifyWindowClosed();
// Set disabled focus to prevent lost focus.
this.setEmptyMathML();
this.customEditors.disable();
}
/**
* Sets an empty MathML as {@link ContentManager.editor} content.
* This will open the MT/CT editor with the hand mode.
* It adds dir rtl in case of it's activated.
*/
setEmptyMathML() {
const isMobile = this.deviceProperties.isAndroid || this.deviceProperties.isIOS;
const isRTL = this.editor.getEditorModel().isRTL();
if (isMobile || this.integrationModel.forcedHandMode) {
// For mobile devices or forced hand mode, set an empty annotation MATHML to maintain the editor in Hand mode.
const mathML = `<math${isRTL ? ' dir="rtl"' : ""}><semantics><annotation encoding="application/json">[]</annotation></semantics></math>`;
this.setMathML(mathML, true);
} else {
// For non-mobile devices or not forced hand mode, set the empty MathML without an annotation.
const mathML = `<math${isRTL ? ' dir="rtl"' : ""}/>`;
this.setMathML(mathML, true);
}
}
/**
* Open event. Triggered by {@link ModalDialog.open}. Does the following:
* - Updates the {@link ContentManager.editor} content
* (with an empty MathML or an existing formula),
* - Updates the {@link ContentManager.editor} toolbar.
* - Recovers the the focus.
*/
onOpen() {
if (this.isNewElement) {
this.setEmptyMathML();
} else {
this.setMathML(this.mathML);
}
const toolbar = this.updateToolbar();
this.onFocus();
if (this.deviceProperties.isIOS) {
const zoom = document.documentElement.clientWidth / window.innerWidth;
if (zoom !== 1) {
// Open editor in Keyboard mode if user use iOS, Safari and page is zoomed.
this.setKeyboardMode();
}
}
const trigger = this.dbclick ? "formula" : "button";
// Call Telemetry service to track the event.
try {
Telemeter.telemeter.track("OPENED_MTCT_EDITOR", {
toolbar,
trigger,
});
} catch (error) {
console.error("Error tracking OPENED_MTCT_EDITOR", error);
}
Core.globalListeners.fire("onModalOpen", {});
if (this.integrationModel.forcedHandMode) {
this.hideHandModeButton();
// In case we have a keyboard written formula, we still want it to be opened with handMode.
if (this.mathML && !this.mathML.includes('<annotation encoding="application/json">') && !this.isNewElement) {
this.openHandOnKeyboardMathML(this.mathML, this.editor);
}
}
}
/**
* Change Editor in keyboard mode when is loaded
*/
setKeyboardMode() {
const wrsEditor = document.getElementsByClassName("wrs_handOpen wrs_disablePalette")[0];
if (wrsEditor) {
wrsEditor.classList.remove("wrs_handOpen");
wrsEditor.classList.remove("wrs_disablePalette");
} else {
setTimeout(ContentManager.prototype.setKeyboardMode.bind(this), 100);
}
}
/**
* Hides the hand <-> keyboard mode switch.
*
* This method relies completely on the classes used on different HTML elements within the editor itself, meaning
* any change on those classes will make this code stop working properly.
*
* On top of that, some of those classes are changed on runtime (for example, the one that makes some buttons change).
* This forces us to use some delayed code (this is, a timeout) to make sure everything exists when we need it.
* @param {*} forced (boolean) Forces the user to stay in Hand mode by hiding the keyboard mode button.
*/
hideHandModeButton(forced = true) {
if (this.handSwitchHidden) {
return; // hand <-> keyboard button already hidden.
}
// "Open hand mode" button takes a little bit to be available.
// This selector gets the hand <-> keyboard mode switch
const handModeButtonSelector =
"div.wrs_editor.wrs_flexEditor.wrs_withHand.wrs_animated .wrs_handWrapper input[type=button]";
// If in "forced mode", we hide the "keyboard button" so the user can't can't change between hand and keyboard modes.
// We use an observer to ensure that the button it hidden as soon as it appears.
if (forced) {
const mutationInstance = new MutationObserver((mutations) => {
const handModeButton = document.querySelector(handModeButtonSelector);
if (handModeButton) {
handModeButton.hidden = true;
this.handSwitchHidden = true;
mutationInstance.disconnect();
}
});
mutationInstance.observe(document.body, {
attributes: true,
childList: true,
characterData: true,
subtree: true,
});
}
}
/**
* It will open any formula written in Keyboard mode with the hand mode with the default hand trace.
*
* @param {String} mathml The original KeyBoard MathML
* @param {Object} editor The editor object.
*/
async openHandOnKeyboardMathML(mathml, editor) {
// First, as an editor requirement, we need to update the editor object with the current MathML formula.
// Once the MathML formula is updated to the one we want to open with handMode, we will be able to proceed.
await new Promise((resolve) => {
editor.setMathMLWithCallback(mathml, resolve);
});
// We wait until the hand editor object exists.
await this.waitForHand(editor);
// Logic to get the hand traces and open the formula in hand mode.
// This logic comes from the editor.
const handEditor = editor.hand;
editor.handTemporalMathML = editor.getMathML();
const handCoordinates = editor.editorModel.getHandStrokes();
handEditor.setStrokes(handCoordinates);
handEditor.fitStrokes(true);
editor.openHand();
}
/**
* Waits until the hand editor object exists.
* @param {Obect} editor The editor object.
*/
async waitForHand(editor) {
while (!editor.hand) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
/**
* Sets the correct toolbar depending if exist other custom toolbars
* at the same time (e.g: Chemistry).
*/
updateToolbar() {
this.updateTitle(this.modalDialogInstance);
const customEditor = this.customEditors.getActiveEditor();
let toolbar;
if (customEditor) {
toolbar = customEditor.toolbar ? customEditor.toolbar : _wrs_int_wirisProperties.toolbar;
if (this.toolbar == null || this.toolbar !== toolbar) {
this.setToolbar(toolbar);
}
} else {
toolbar = this.getToolbar();
if (this.toolbar == null || this.toolbar !== toolbar) {
this.setToolbar(toolbar);
this.customEditors.disable();
}
}
return toolbar;
}
/**
* Updates the current {@link ModalDialog.title}. If a {@link CustomEditors} is enabled
* sets the custom editor title. Otherwise sets the default title.
*/
updateTitle() {
const customEditor = this.customEditors.getActiveEditor();
if (customEditor) {
this.modalDialogInstance.setTitle(customEditor.title);
} else {
this.modalDialogInstance.setTitle("MathType");
}
}
/**
* Returns the editor toolbar, depending on the configuration local or server side.
* @returns {String} - Toolbar identifier.
*/
getToolbar() {
let toolbar = "general";
if ("toolbar" in this.editorAttributes) {
({ toolbar } = this.editorAttributes);
}
// TODO: Change global integration variable for integration custom toolbar.
if (toolbar === "general") {
// eslint-disable-next-line camelcase
toolbar =
typeof _wrs_int_wirisProperties === "undefined" || typeof _wrs_int_wirisProperties.toolbar === "undefined"
? "general"
: _wrs_int_wirisProperties.toolbar;
}
return toolbar;
}
/**
* Sets the current {@link ContentManager.editor} instance toolbar.
* @param {String} toolbar - The toolbar name.
*/
setToolbar(toolbar) {
this.toolbar = toolbar;
this.editor.setParams({ toolbar: this.toolbar });
}
/**
* Sets the custom headers added on editor requests.
* @returns {Object} headers - key value headers.
*/
setCustomHeaders(headers) {
let headersObj = {};
// We control that we only get String or Object as the input.
if (typeof headers === "object") {
headersObj = headers;
} else if (typeof headers === "string") {
headersObj = Util.convertStringToObject(headers);
}
this.editor.setParams({ customHeaders: headersObj });
return headersObj;
}
/**
* Returns true if the content of the editor has been changed. The logic of the changes
* is delegated to {@link EditorListener} class.
* @returns {Boolean} True if the editor content has been changed. False otherwise.
*/
hasChanges() {
return !this.editor.isFormulaEmpty() && this.editorListener.getIsContentChanged();
}
/**
* Handle keyboard events detected in modal when elements of this class intervene.
* @param {KeyboardEvent} keyboardEvent - The keyboard event.
*/
onKeyDown(keyboardEvent) {
if (keyboardEvent.key !== undefined && keyboardEvent.repeat === false) {
if (keyboardEvent.key === "Escape" || keyboardEvent.key === "Esc") {
// Code to detect Esc event.
// There should be only one element with class name 'wrs_pressed' at the same time.
let list = document.getElementsByClassName("wrs_expandButton wrs_expandButtonFor3RowsLayout wrs_pressed");
if (list.length === 0) {
list = document.getElementsByClassName("wrs_expandButton wrs_expandButtonFor2RowsLayout wrs_pressed");
if (list.length === 0) {
list = document.getElementsByClassName("wrs_select wrs_pressed");
if (list.length === 0) {
this.modalDialogInstance.cancelAction();
keyboardEvent.stopPropagation();
keyboardEvent.preventDefault();
}
}
}
} else if (keyboardEvent.shiftKey && keyboardEvent.key === "Tab") {
// Code to detect shift Tab event.
if (document.activeElement === this.modalDialogInstance.submitButton) {
// Focus is on OK button.
this.editor.focus();
keyboardEvent.stopPropagation();
keyboardEvent.preventDefault();
} else if (document.querySelector('[title="Manual"]') === document.activeElement) {
// Focus is on minimize button (_).
this.modalDialogInstance.closeDiv.focus();
keyboardEvent.stopPropagation();
keyboardEvent.preventDefault();
} else if (document.activeElement === this.modalDialogInstance.minimizeDiv) {
// Focus on cancel button.
if (!(this.modalDialogInstance.properties.state === "minimized")) {
this.modalDialogInstance.cancelButton.focus();
keyboardEvent.stopPropagation();
keyboardEvent.preventDefault();
}
}
} else if (keyboardEvent.key === "Tab") {
// Code to detect Tab event.
if (document.activeElement === this.modalDialogInstance.cancelButton) {
// Focus is on X button.
this.modalDialogInstance.minimizeDiv.focus();
keyboardEvent.stopPropagation();
keyboardEvent.preventDefault();
} else if (document.activeElement === this.modalDialogInstance.closeDiv) {
// Focus on help button.
if (!(this.modalDialogInstance.properties.state === "minimized")) {
const element = document.querySelector('[title="Manual"]');
element.focus();
keyboardEvent.stopPropagation();
keyboardEvent.preventDefault();
}
} else {
// There should be only one element with class name 'wrs_formulaDisplay'.
const element = document.getElementsByClassName("wrs_formulaDisplay")[0];
if (element.getAttribute("class") === "wrs_formulaDisplay wrs_focused") {
// Focus is on formuladisplay.
this.modalDialogInstance.submitButton.focus();
keyboardEvent.stopPropagation();
keyboardEvent.preventDefault();
}
}
}
}
}
}