@wiris/mathtype-ckeditor5
Version:
MathType Web for CKEditor5 editor
560 lines (460 loc) • 20.1 kB
JavaScript
// CKEditor imports
import { Plugin } from "ckeditor5/src/core.js";
import { ButtonView } from "ckeditor5/src/ui.js";
import { ClickObserver, HtmlDataProcessor, XmlDataProcessor, ViewUpcastWriter } from "ckeditor5/src/engine.js";
import { Widget, toWidget, viewToModelPositionOutsideModelElement } from "ckeditor5/src/widget.js";
// MathType API imports
import IntegrationModel from "@wiris/mathtype-html-integration-devkit/src/integrationmodel.js";
import Core from "@wiris/mathtype-html-integration-devkit/src/core.src.js";
import Parser from "@wiris/mathtype-html-integration-devkit/src/parser.js";
import Util from "@wiris/mathtype-html-integration-devkit/src/util.js";
import Image from "@wiris/mathtype-html-integration-devkit/src/image.js";
import Configuration from "@wiris/mathtype-html-integration-devkit/src/configuration.js";
import Listeners from "@wiris/mathtype-html-integration-devkit/src/listeners.js";
import MathML from "@wiris/mathtype-html-integration-devkit/src/mathml.js";
import Latex from "@wiris/mathtype-html-integration-devkit/src/latex.js";
import StringManager from "@wiris/mathtype-html-integration-devkit/src/stringmanager.js";
import "@wiris/mathtype-html-integration-devkit/src/md5.js";
// Local imports
import { MathTypeCommand, ChemTypeCommand } from "./commands.js";
import CKEditor5Integration from "./integration.js";
import mathIcon from "../theme/icons/ckeditor5-formula.svg";
import chemIcon from "../theme/icons/ckeditor5-chem.svg";
import packageInfo from "../package.json";
export let currentInstance = null; // eslint-disable-line import/no-mutable-exports
export default class MathType extends Plugin {
static get requires() {
return [Widget];
}
static get pluginName() {
return "MathType";
}
init() {
// Create the MathType API Integration object
const integration = this._addIntegration();
currentInstance = integration;
// Add the MathType and ChemType commands to the editor
this._addCommands();
// Add the buttons for MathType and ChemType
this._addViews(integration);
// Registers the <mathml> element in the schema
this._addSchema();
// Add the downcast and upcast converters
this._addConverters(integration);
// Expose the WirisPlugin variable to the window
this._exposeWiris();
}
/**
* Inherited from Plugin class: Executed when CKEditor5 is destroyed
*/
destroy() {
// eslint-disable-line class-methods-use-this
currentInstance?.destroy();
}
/**
* Create the MathType API Integration object
* @returns {CKEditor5Integration} the integration object
*/
_addIntegration() {
const { editor } = this;
/**
* Integration model constructor attributes.
* @type {integrationModelProperties}
*/
const integrationProperties = {};
integrationProperties.environment = {};
integrationProperties.environment.editor = "CKEditor5";
integrationProperties.environment.editorVersion = "5.x";
integrationProperties.version = packageInfo.version;
integrationProperties.editorObject = editor;
integrationProperties.serviceProviderProperties = {};
integrationProperties.serviceProviderProperties.URI = "https://www.wiris.net/demo/plugins/app";
integrationProperties.serviceProviderProperties.server = "java";
integrationProperties.target = editor.sourceElement;
integrationProperties.scriptName = "bundle.js";
integrationProperties.managesLanguage = true;
// etc
// There are platforms like Drupal that initialize CKEditor but they hide or remove the container element.
// To avoid a wrong behavior, this integration only starts if the workspace container exists.
let integration;
if (integrationProperties.target) {
// Instance of the integration associated to this editor instance
integration = new CKEditor5Integration(integrationProperties);
integration.init();
integration.listeners.fire("onTargetReady", {});
integration.checkElement();
this.listenTo(
editor.editing.view.document,
"click",
(evt, data) => {
// Is Double-click
if (data.domEvent.detail === 2) {
integration.doubleClickHandler(data.domTarget, data.domEvent);
evt.stop();
}
},
{ priority: "highest" },
);
}
return integration;
}
/**
* Add the MathType and ChemType commands to the editor
*/
_addCommands() {
const { editor } = this;
// Add command to open the formula editor
editor.commands.add("MathType", new MathTypeCommand(editor));
// Add command to open the chemistry formula editor
editor.commands.add("ChemType", new ChemTypeCommand(editor));
}
/**
* Add the buttons for MathType and ChemType
* @param {CKEditor5Integration} integration the integration object
*/
_addViews(integration) {
const { editor } = this;
// Check if MathType editor is enabled
if (Configuration.get("editorEnabled")) {
// Add button for the formula editor
editor.ui.componentFactory.add("MathType", (locale) => {
const view = new ButtonView(locale);
// View is enabled iff command is enabled
view.bind("isEnabled").to(editor.commands.get("MathType"), "isEnabled");
view.set({
label: StringManager.get("insert_math", integration.getLanguage()),
icon: mathIcon,
tooltip: true,
});
// Callback executed once the image is clicked.
view.on("execute", () => {
editor.execute("MathType", {
integration, // Pass integration as parameter
});
});
return view;
});
}
// Check if ChemType editor is enabled
if (Configuration.get("chemEnabled")) {
// Add button for the chemistry formula editor
editor.ui.componentFactory.add("ChemType", (locale) => {
const view = new ButtonView(locale);
// View is enabled iff command is enabled
view.bind("isEnabled").to(editor.commands.get("ChemType"), "isEnabled");
view.set({
label: StringManager.get("insert_chem", integration.getLanguage()),
icon: chemIcon,
tooltip: true,
});
// Callback executed once the image is clicked.
view.on("execute", () => {
editor.execute("ChemType", {
integration, // Pass integration as parameter
});
});
return view;
});
}
// Observer for the Double-click event
editor.editing.view.addObserver(ClickObserver);
}
/**
* Registers the <mathml> element in the schema
*/
_addSchema() {
const { schema } = this.editor.model;
schema.register("mathml", {
inheritAllFrom: "$inlineObject",
allowAttributes: ["formula", "htmlContent"],
});
}
/**
* Add the downcast and upcast converters
*/
_addConverters(integration) {
const { editor } = this;
// Editing view -> Model
editor.conversion.for("upcast").elementToElement({
view: {
name: "span",
classes: "ck-math-widget",
},
model: (viewElement, { writer: modelWriter }) => {
const formula = MathML.safeXmlDecode(viewElement.getChild(0).getAttribute("data-mathml"));
return modelWriter.createElement("mathml", {
formula,
});
},
});
// Data view -> Model
editor.data.upcastDispatcher.on("element:math", (evt, data, conversionApi) => {
const { consumable, writer } = conversionApi;
const { viewItem } = data;
// When element was already consumed then skip it.
if (!consumable.test(viewItem, { name: true })) {
return;
}
// If we encounter any <math> with a LaTeX annotation inside,
// convert it into a "$$...$$" string.
const isLatex = mathIsLatex(viewItem); // eslint-disable-line no-use-before-define
// Get the formula of the <math> (which is all its children).
const processor = new XmlDataProcessor(editor.editing.view.document);
// Only god knows why the following line makes viewItem lose all of its children,
// so we obtain isLatex before doing this because we need viewItem's children for that.
const upcastWriter = new ViewUpcastWriter(editor.editing.view.document);
const viewDocumentFragment = upcastWriter.createDocumentFragment(viewItem.getChildren());
// and obtain the attributes of <math> too!
const mathAttributes = [...viewItem.getAttributes()].map(([key, value]) => ` ${key}="${value}"`).join("");
// We process the document fragment
let formula = processor.toData(viewDocumentFragment) || "";
// And obtain the complete formula
formula = Util.htmlSanitize(`<math${mathAttributes}>${formula}</math>`);
// Replaces the < & > characters to its HTMLEntity to avoid render issues.
formula = formula.replaceAll('"<"', '"<"').replaceAll('">"', '">"').replaceAll("><<", "><<");
/* Model node that contains what's going to actually be inserted. This can be either:
- A <mathml> element with a formula attribute set to the given formula, or
- If the original <math> had a LaTeX annotation, then the annotation surrounded by "$$...$$" */
const modelNode = isLatex
? writer.createText(Parser.initParse(formula, integration.getLanguage()))
: writer.createElement("mathml", { formula });
// Find allowed parent for element that we are going to insert.
// If current parent does not allow to insert element but one of the ancestors does
// then split nodes to allowed parent.
const splitResult = conversionApi.splitToAllowedParent(modelNode, data.modelCursor);
// When there is no split result it means that we can't insert element to model tree, so let's skip it.
if (!splitResult) {
return;
}
// Insert element on allowed position.
conversionApi.writer.insert(modelNode, splitResult.position);
// Consume appropriate value from consumable values list.
consumable.consume(viewItem, { name: true });
const parts = conversionApi.getSplitParts(modelNode);
// Set conversion result range.
data.modelRange = writer.createRange(
conversionApi.writer.createPositionBefore(modelNode),
conversionApi.writer.createPositionAfter(parts[parts.length - 1]),
);
// Now we need to check where the `modelCursor` should be.
if (splitResult.cursorParent) {
// If we split parent to insert our element then we want to continue conversion in the new part of the split parent.
//
// before: <allowed><notAllowed>foo[]</notAllowed></allowed>
// after: <allowed><notAllowed>foo</notAllowed><converted></converted><notAllowed>[]</notAllowed></allowed>
data.modelCursor = conversionApi.writer.createPositionAt(splitResult.cursorParent, 0);
} else {
// Otherwise just continue after inserted element.
data.modelCursor = data.modelRange.end;
}
});
// Data view -> Model
editor.data.upcastDispatcher.on("element:img", (evt, data, conversionApi) => {
const { consumable, writer } = conversionApi;
const { viewItem } = data;
// Only upcast when is wiris formula
if (viewItem.getClassNames().next().value !== "Wirisformula") {
return;
}
const mathAttributes = [...viewItem.getAttributes()].map(([key, value]) => ` ${key}="${value}"`).join("");
const htmlContent = Util.htmlSanitize(`<img${mathAttributes}>`);
const modelNode = writer.createElement("mathml", { htmlContent });
// Find allowed parent for element that we are going to insert.
// If current parent does not allow to insert element but one of the ancestors does
// then split nodes to allowed parent.
const splitResult = conversionApi.splitToAllowedParent(modelNode, data.modelCursor);
// When there is no split result it means that we can't insert element to model tree, so let's skip it.
if (!splitResult) {
return;
}
// Insert element on allowed position.
conversionApi.writer.insert(modelNode, splitResult.position);
// Consume appropriate value from consumable values list.
consumable.consume(viewItem, { name: true });
const parts = conversionApi.getSplitParts(modelNode);
// Set conversion result range.
data.modelRange = writer.createRange(
conversionApi.writer.createPositionBefore(modelNode),
conversionApi.writer.createPositionAfter(parts[parts.length - 1]),
);
// Now we need to check where the `modelCursor` should be.
if (splitResult.cursorParent) {
// If we split parent to insert our element then we want to continue conversion in the new part of the split parent.
//
// before: <allowed><notAllowed>foo[]</notAllowed></allowed>
// after: <allowed><notAllowed>foo</notAllowed><converted></converted><notAllowed>[]</notAllowed></allowed>
data.modelCursor = conversionApi.writer.createPositionAt(splitResult.cursorParent, 0);
} else {
// Otherwise just continue after inserted element.
data.modelCursor = data.modelRange.end;
}
});
/**
* Whether the given view <math> element has a LaTeX annotation element.
* @param {*} math
* @returns {bool}
*/
function mathIsLatex(math) {
const semantics = math.getChild(0);
if (!semantics || semantics.name !== "semantics") return false;
for (const child of semantics.getChildren()) {
if (child.name === "annotation" && child.getAttribute("encoding") === "LaTeX") {
return true;
}
}
return false;
}
function createViewWidget(modelItem, { writer: viewWriter }) {
const widgetElement = viewWriter.createContainerElement("span", {
class: "ck-math-widget",
});
const mathUIElement = createViewImage(modelItem, { writer: viewWriter }); // eslint-disable-line no-use-before-define
if (mathUIElement) {
viewWriter.insert(viewWriter.createPositionAt(widgetElement, 0), mathUIElement);
}
return toWidget(widgetElement, viewWriter);
}
function createViewImage(modelItem, { writer: viewWriter }) {
const htmlDataProcessor = new HtmlDataProcessor(viewWriter.document);
const formula = modelItem.getAttribute("formula");
const htmlContent = modelItem.getAttribute("htmlContent");
if (!formula && !htmlContent) {
return null;
}
let imgElement = null;
if (htmlContent) {
imgElement = htmlDataProcessor.toView(htmlContent).getChild(0);
} else if (formula) {
const mathString = formula.replaceAll('ref="<"', 'ref="<"');
const imgHtml = Parser.initParse(mathString, integration.getLanguage());
imgElement = htmlDataProcessor.toView(imgHtml).getChild(0);
// Add HTML element (<img>) to model
viewWriter.setAttribute("htmlContent", imgHtml, modelItem);
}
/* Although we use the HtmlDataProcessor to obtain the attributes,
* we must create a new EmptyElement which is independent of the
* DataProcessor being used by this editor instance
*/
if (imgElement) {
return viewWriter.createEmptyElement("img", imgElement.getAttributes(), {
renderUnsafeAttributes: ["src"],
});
}
return null;
}
// Model -> Editing view
editor.conversion.for("editingDowncast").elementToElement({
model: "mathml",
view: createViewWidget,
});
// Model -> Data view
editor.conversion.for("dataDowncast").elementToElement({
model: "mathml",
view: createDataString, // eslint-disable-line no-use-before-define
});
/**
* Makes a copy of the given view node.
* @param {module:engine/view/node~Node} sourceNode Node to copy.
* @returns {module:engine/view/node~Node} Copy of the node.
*/
function clone(viewWriter, sourceNode) {
if (sourceNode.is("text")) {
return viewWriter.createText(sourceNode.data);
}
if (sourceNode.is("element")) {
if (sourceNode.is("emptyElement")) {
return viewWriter.createEmptyElement(sourceNode.name, sourceNode.getAttributes());
}
const element = viewWriter.createContainerElement(sourceNode.name, sourceNode.getAttributes());
for (const child of sourceNode.getChildren()) {
viewWriter.insert(viewWriter.createPositionAt(element, "end"), clone(viewWriter, child));
}
return element;
}
throw new Exception("Given node has unsupported type."); // eslint-disable-line no-undef
}
function createDataString(modelItem, { writer: viewWriter }) {
const htmlDataProcessor = new HtmlDataProcessor(viewWriter.document);
// Load img element
const mathString =
modelItem.getAttribute("htmlContent") || Parser.endParseSaveMode(modelItem.getAttribute("formula"));
const sourceMathElement = htmlDataProcessor.toView(mathString).getChild(0);
return clone(viewWriter, sourceMathElement);
}
// This stops the view selection getting into the <span>s and messing up caret movement
editor.editing.mapper.on(
"viewToModelPosition",
viewToModelPositionOutsideModelElement(editor.model, (viewElement) => viewElement.hasClass("ck-math-widget")),
);
// Keep a reference to the original get and set function.
const { get, set } = editor.data;
/**
* Hack to transform $$latex$$ into <math> in editor.getData()'s output.
*/
editor.data.on(
"get",
(e) => {
const output = e.return;
const parsedResult = Parser.endParse(output);
// Cleans all the semantics tag for safexml
// including the handwritten data points
e.return = MathML.removeSafeXMLSemantics(parsedResult);
},
{ priority: "low" },
);
/**
* Hack to transform <math> with LaTeX into $$LaTeX$$ and formula images in editor.setData().
*/
editor.data.on(
"set",
(e, args) => {
// Retrieve the data to be set on the CKEditor.
let modifiedData = args[0];
// Regex to find all mathml formulas.
const regexp = /(<img\b[^>]*>)|(<math(.*?)<\/math>)/gm;
const formulas = [];
let formula;
// Both data.set from the source plugin and console command are taken into account as the data received is MathML or an image containing the MathML.
while ((formula = regexp.exec(modifiedData)) !== null) {
formulas.push(formula[0]);
}
// Loop to find LaTeX and formula images and replace the MathML for the both.
formulas.forEach((formula) => {
if (formula.includes('encoding="LaTeX"')) {
// LaTeX found.
const latex = `$$$${Latex.getLatexFromMathML(formula)}$$$`; // We add $$$ instead of $$ because the replace function ignores one $.
modifiedData = modifiedData.replace(formula, latex);
} else if (formula.includes("<img")) {
// If we found a formula image, we should find MathML data, and then substitute the entire image.
const regexp = /«math\b[^»]*»(.*?)«\/math»/g;
const safexml = formula.match(regexp);
if (safexml !== null) {
const decodeXML = MathML.safeXmlDecode(safexml[0]);
modifiedData = modifiedData.replace(formula, decodeXML);
}
}
});
args[0] = modifiedData;
},
{ priority: "high" },
);
}
/**
* Expose the WirisPlugin variable to the window
*/
// eslint-disable-next-line class-methods-use-this
_exposeWiris() {
window.WirisPlugin = {
Core,
Parser,
Image,
MathML,
Util,
Configuration,
Listeners,
IntegrationModel,
currentInstance,
Latex,
};
}
}