UNPKG

@wiris/mathtype-ckeditor5

Version:

MathType Web for CKEditor5 editor

702 lines (575 loc) 24.4 kB
import IntegrationModel from "@wiris/mathtype-html-integration-devkit/src/integrationmodel.js"; import Util from "@wiris/mathtype-html-integration-devkit/src/util.js"; import Configuration from "@wiris/mathtype-html-integration-devkit/src/configuration.js"; import Latex from "@wiris/mathtype-html-integration-devkit/src/latex.js"; import MathML from "@wiris/mathtype-html-integration-devkit/src/mathml.js"; import Telemeter from "@wiris/mathtype-html-integration-devkit/src/telemeter.js"; /** * This class represents the MathType integration for CKEditor5. * @extends {IntegrationModel} */ export default class CKEditor5Integration extends IntegrationModel { constructor(ckeditorIntegrationModelProperties) { const editor = ckeditorIntegrationModelProperties.editorObject; if (typeof editor.config !== "undefined" && typeof editor.config.get("mathTypeParameters") !== "undefined") { ckeditorIntegrationModelProperties.integrationParameters = editor.config.get("mathTypeParameters"); } /** * CKEditor5 Integration. * * @param {integrationModelProperties} integrationModelAttributes */ super(ckeditorIntegrationModelProperties); /** * Folder name used for the integration inside CKEditor plugins folder. */ this.integrationFolderName = "ckeditor_wiris"; } /** * @inheritdoc * @returns {string} - The CKEditor instance language. * @override */ getLanguage() { // Returns the CKEDitor instance language taking into account that the language can be an object. // Try to get editorParameters.language, fail silently otherwise try { return this.editorParameters.language; } catch (e) {} const languageObject = this.editorObject.config.get("language"); if (languageObject != null) { if (typeof languageObject === "object") { if (Object.prototype.hasOwnProperty.call(languageObject, "ui")) { return languageObject.ui; } return this.editorObject.locale.uiLanguage; } return languageObject; } return super.getLanguage(); } /** * Adds callbacks to the following CKEditor listeners: * - 'focus' - updates the current instance. * - 'contentDom' - adds 'doubleclick' callback. * - 'doubleclick' - sets to null data.dialog property to avoid modifications for MathType formulas. * - 'setData' - parses the data converting MathML into images. * - 'afterSetData' - adds an observer to MathType formulas to avoid modifications. * - 'getData' - parses the data converting images into selected save mode (MathML by default). * - 'mode' - recalculates the active element. */ addEditorListeners() { const editor = this.editorObject; if (typeof editor.config.wirislistenersdisabled === "undefined" || !editor.config.wirislistenersdisabled) { this.checkElement(); } } /** * Checks the current container and assign events in case that it doesn't have them. * CKEditor replaces several times the element element during its execution, * so we must assign the events again to editor element. */ checkElement() { const editor = this.editorObject; const newElement = editor.sourceElement; // If the element wasn't treated, add the events. if (!newElement.wirisActive) { this.setTarget(newElement); this.addEvents(); // Set the element as treated newElement.wirisActive = true; } } /** * @inheritdoc * @param {HTMLElement} element - HTMLElement target. * @param {MouseEvent} event - event which trigger the handler. */ doubleClickHandler(element, event) { this.core.editionProperties.dbclick = true; if (this.editorObject.isReadOnly === false) { if (element.nodeName.toLowerCase() === "img") { if (Util.containsClass(element, Configuration.get("imageClassName"))) { // Some plugins (image2, image) open a dialog on Double-click. On formulas // doubleclick event ends here. if (typeof event.stopPropagation !== "undefined") { // old I.E compatibility. event.stopPropagation(); } else { event.returnValue = false; } this.core.getCustomEditors().disable(); const customEditorAttr = element.getAttribute(Configuration.get("imageCustomEditorName")); if (customEditorAttr) { this.core.getCustomEditors().enable(customEditorAttr); } this.core.editionProperties.temporalImage = element; this.openExistingFormulaEditor(); } } } } /** @inheritdoc */ static getCorePath() { return null; // TODO } /** @inheritdoc */ callbackFunction() { super.callbackFunction(); this.addEditorListeners(); } openNewFormulaEditor() { // Store the editor selection as it will be lost upon opening the modal this.core.editionProperties.selection = this.editorObject.editing.view.document.selection; // Focus on the selected editor when multiple editor instances are present WirisPlugin.currentInstance = this; return super.openNewFormulaEditor(); } /** * Replaces old formula with new MathML or inserts it in caret position if new * @param {String} mathml MathML to update old one or insert * @returns {module:engine/model/element~Element} The model element corresponding to the inserted image */ insertMathml(mathml) { return this.editorObject.model.change((writer) => { const { isNewElement, temporalImage } = this.getCore().editionProperties; const selection = this.editorObject.model.document.selection; const attributes = Object.fromEntries(selection.getAttributes()); const modelElementNew = writer.createElement("mathml", { formula: mathml, ...attributes }); if (isNewElement) { return this.insertNewFormula(writer, mathml, modelElementNew); } return this.replaceExistingFormula(mathml, modelElementNew, temporalImage); }); } /** * Inserts a new formula at the current selection position. */ insertNewFormula(writer, mathml, modelElement) { if (!mathml) { return; } const viewSelection = this.core.editionProperties.selection || this.editorObject.editing.view.document.selection; const modelPosition = this.editorObject.editing.mapper.toModelPosition(viewSelection.getLastPosition()); this.editorObject.model.insertObject(modelElement, modelPosition); this.deleteViewSelection(viewSelection); // Set carret after the formula. const position = this.editorObject.model.createPositionAfter(modelElement); writer.setSelection(position); return modelElement; } deleteViewSelection(viewSelection) { if (viewSelection.isCollapsed) { return; } for (const range of viewSelection.getRanges()) { const modelRange = this.editorObject.editing.mapper.toModelRange(range); const modelSelection = this.editorObject.model.createSelection(modelRange); this.editorObject.model.deleteContent(modelSelection); } } /** * Replaces an existing formula with updated MathML. */ replaceExistingFormula(mathml, modelElement, temporalImage) { const viewNode = this.editorObject.editing.view.domConverter.domToView(temporalImage); // Check if image exists in view to do standard formula editing if (viewNode?.parent) { const modelElementOld = this.editorObject.editing.mapper.toModelElement(viewNode.parent); // Insert the new <mathml> and remove the old one const position = this.editorObject.model.createPositionBefore(modelElementOld); if (mathml) { this.editorObject.model.insertObject(modelElement, position); } this.editorObject.model.deleteContent(this.editorObject.model.createSelection(modelElementOld, "on")); return modelElement; } // Otherwise it's LaTeX editing, so we insert at current selection if (!mathml) { return; } this.editorObject.model.insertContent(modelElement); return modelElement; } /** * Finds the text node corresponding to given DOM text element. * @param {element} viewElement Element to find corresponding text node of. * @returns {module:engine/model/text~Text|undefined} Text node corresponding to the given element or undefined if it doesn't exist. */ findText(viewElement) { // eslint-disable-line consistent-return // mapper always converts text nodes to *new* model elements so we need to convert the text's parents and then come back down let pivot = viewElement; let element; while (!element) { element = this.editorObject.editing.mapper.toModelElement( this.editorObject.editing.view.domConverter.domToView(pivot), ); pivot = pivot.parentElement; } // Navigate through all the subtree under `pivot` in order to find the correct text node const range = this.editorObject.model.createRangeIn(element); const descendants = Array.from(range.getItems()); for (const node of descendants) { let viewElementData = viewElement.data; if (viewElement.nodeType === 3) { // Remove invisible white spaces viewElementData = viewElementData.replaceAll(String.fromCharCode(8288), ""); } if (node.is("textProxy") && node.data === viewElementData.replace(String.fromCharCode(160), " ")) { return node.textNode; } } } /** @inheritdoc */ insertFormula(_focusElement, windowTarget, mathml, _wirisProperties) { // eslint-disable-line no-unused-vars const returnObject = {}; let mathmlOrigin; if (!mathml) { this.insertMathml(""); } else if (this.core.editMode === "latex") { this.handleLatexInsertion(returnObject, windowTarget, mathml); } else { mathmlOrigin = this.handleMathmlInsertion(returnObject, windowTarget, mathml); } const payload = { mathml: mathml ? MathML.safeXmlDecode(mathml) : undefined, elapsed_time: Date.now() - this.core.editionProperties.editionStartTime, toolbar: this.core.modalDialog.contentManager.toolbar, size: mathml?.length, }; if (mathmlOrigin) { payload.mathml_origin = MathML.safeXmlDecode(mathmlOrigin); } try { Telemeter.telemeter.track("INSERTED_FORMULA", { ...payload, }); } catch (error) { console.error("Error tracking INSERTED_FORMULA", error); } this.core.editionProperties.temporalImage = null; return returnObject; } handleLatexInsertion(returnObject, windowTarget, mathml) { returnObject.latex = Latex.getLatexFromMathML(mathml); returnObject.node = windowTarget.document.createTextNode(`$$${returnObject.latex}$$`); const { latexRange } = this.core.editionProperties; // When latexRange exists (meaning the whole LaTeX was selected or the editor was opened), // find the node contaning the LaTeX and replace it fully. if (latexRange) { const startNode = this.findText(latexRange.startContainer); const endNode = this.findText(latexRange.endContainer); // If nodes found, use standard replacement. if (startNode && endNode) { this.replaceLatexWithNodes(startNode, endNode, latexRange, returnObject.latex); return; } } this.replaceLatexUsingModelSearch(returnObject.latex); } handleMathmlInsertion(returnObject, windowTarget, mathml) { const mathmlOrigin = this.core.editionProperties.temporalImage?.dataset.mathml; try { const modelElement = this.insertMathml(mathml); const viewElement = this.editorObject.editing.mapper.toViewElement(modelElement); returnObject.node = this.editorObject.editing.view.domConverter.viewToDom(viewElement, windowTarget.document); } catch (error) { if (error.toString().includes("Cannot read property 'parent' of undefined")) { this.core.modalDialog.cancelAction(); } } return mathmlOrigin; } /** * Gets selection attributes excluding track changes tags. */ getCleanSelectionAttributes() { const attributes = {}; for (const [key, value] of this.editorObject.model.document.selection.getAttributes()) { if (!key.startsWith("suggestion:") && !key.startsWith("comment:")) { attributes[key] = value; } } return attributes; } /** * Searches for the original LaTeX in the model and replaces it. * Fallback when findText() cannot locate DOM nodes (like when there are track changes modifications). */ replaceLatexUsingModelSearch(newLatex) { const foundRange = this.findLatexBlockNearSelection(); if (foundRange) { this.editorObject.model.change((writer) => writer.setSelection(foundRange)); this.replaceRangeWithLatex(newLatex); } else { // Insert at current position as a last resort. this.editorObject.model.change((writer) => { const newLatexText = writer.createText(`$$${newLatex}$$`, this.getCleanSelectionAttributes()); this.editorObject.model.insertContent(newLatexText); }); } this.core.editionProperties.extractedLatex = null; } /** * Checks if a text proxy has a track changes deletion marker. */ isDeletedText(text) { for (const [key, value] of text.getAttributes()) { if (key.startsWith("suggestion:") && value === "deletion") { return true; } } return false; } /** * Finds a LaTeX block ($$...$$) near the current selection. * Handles track changes by considering the "accepted" version of text. */ findLatexBlockNearSelection() { const position = this.editorObject.model.document.selection.getFirstPosition(); if (!position?.parent) { return; } // Build LaTeX with track changes accepted suggestions, if any. const { textParts, acceptedText } = this.collectTextParts(position.parent); if (!acceptedText.includes("$$")) { return; } // To handle multiple LaTeX on same line. const targetLatex = this.core.editionProperties.extractedLatex; const fullLatex = `$$${targetLatex}$$`; const startIndex = acceptedText.indexOf(fullLatex); if (startIndex === -1) { return; } const latexBoundaries = { start: startIndex, end: startIndex + fullLatex.length }; return this.convertAcceptedOffsetsToModelRange(textParts, latexBoundaries); } /** * Collects all text fragments from a paragraph, tracking both model and accepted text positions. * This is necessary to handle track changes where some LaTeX may have suggestions. */ collectTextParts(paragraph) { const textParts = []; let acceptedTextOffset = 0; let acceptedText = ""; for (const item of this.editorObject.model.createRangeIn(paragraph).getItems()) { if (item.is("$textProxy")) { const isDeleted = this.isDeletedText(item); textParts.push({ text: item.data, startOffset: item.startOffset, endOffset: item.startOffset + item.data.length, parent: item.textNode.parent, acceptedStart: isDeleted ? null : acceptedTextOffset, acceptedEnd: isDeleted ? null : acceptedTextOffset + item.data.length, isDeleted }); if (!isDeleted) { acceptedText += item.data; acceptedTextOffset += item.data.length; } } } return { textParts, acceptedText }; } /** * Converts LaTeX with track changes accepted suggestions to a CKEditor model Range. */ convertAcceptedOffsetsToModelRange(textParts, latexBoundaries) { let startPartIndex = -1, endPartIndex = -1; let startOffsetInPart = 0, endOffsetInPart = 0; // Find which text parts contain the LaTeX block boundaries for (let i = 0; i < textParts.length; i++) { const part = textParts[i]; if (part.isDeleted) continue; if (startPartIndex === -1 && latexBoundaries.start >= part.acceptedStart && latexBoundaries.start <= part.acceptedEnd) { startPartIndex = i; startOffsetInPart = latexBoundaries.start - part.acceptedStart; } if (latexBoundaries.end >= part.acceptedStart && latexBoundaries.end <= part.acceptedEnd) { endPartIndex = i; endOffsetInPart = latexBoundaries.end - part.acceptedStart; } } if (startPartIndex === -1 || endPartIndex === -1) { return; } // Extend range to include any consecutive deleted parts after the block. let finalEndIndex = endPartIndex; let finalEndOffset = endOffsetInPart; for (let i = endPartIndex + 1; i < textParts.length && textParts[i].isDeleted; i++) { finalEndIndex = i; finalEndOffset = textParts[i].text.length; } const startPart = textParts[startPartIndex]; const endPart = textParts[finalEndIndex]; return this.editorObject.model.createRange( this.editorObject.model.createPositionAt(startPart.parent, startPart.startOffset + startOffsetInPart), this.editorObject.model.createPositionAt(endPart.parent, endPart.startOffset + finalEndOffset) ); } replaceRangeWithLatex(newLatex) { this.editorObject.model.change((writer) => { this.editorObject.model.deleteContent(this.editorObject.model.document.selection); const newLatexText = writer.createText(`$$${newLatex}$$`, this.getCleanSelectionAttributes()); this.editorObject.model.insertContent(newLatexText); }); } /** * Replaces the whole LaTeX in the CKEditor5 model. */ replaceLatexWithNodes(startNode, endNode, latexRange, newLatex) { this.editorObject.model.change((writer) => { const startOffset = startNode.startOffset + latexRange.startOffset; const endOffset = endNode.startOffset + latexRange.endOffset; let startPosition = writer.createPositionAt(startNode.parent, startOffset); let endPosition = writer.createPositionAt(endNode.parent, endOffset); // Adjust positions when LaTeX is adjacent to a formula. const startContainer = latexRange.startContainer; if (startContainer.nodeType === Node.TEXT_NODE && startContainer.previousSibling?.nodeType === Node.ELEMENT_NODE) { const originalLatex = `$$${Latex.getLatexFromMathML( MathML.safeXmlDecode(this.core.editionProperties.temporalImage.dataset.mathml), )}$$`; const textData = startContainer.data.replaceAll(String.fromCodePoint(8288), ""); const latexOffset = textData.indexOf(originalLatex); if (latexOffset !== -1) { const closingDelimiterOffset = textData.substring(latexOffset + 2).indexOf("$$") + 4; startPosition = writer.createPositionAt(startNode.parent, startNode.startOffset + latexOffset); endPosition = writer.createPositionAt(endNode.parent, endNode.startOffset + closingDelimiterOffset + latexOffset); } } writer.setSelection(writer.createRange(startPosition, endPosition)); }); this.replaceRangeWithLatex(newLatex); } /** * Inherited method from IntegrationModel. * Gets the MathML from a text node containing LaTeX. * Handles track changes by simulating "accept all changes" before conversion. */ getMathmlFromTextNode(textNode, caretPosition) { const standardResult = Latex.getLatexFromTextNode(textNode, caretPosition); const acceptedLatex = this.extractAcceptedLatexFromDOM(textNode, caretPosition); // Prioritize accepted LaTeX if it differs from standard extraction (for track changes compatibility). // Important node: use explicit undefined check to allow empty LaTeX strings, otherwise it would not detect $$$$ as valid LaTeX. const latex = (acceptedLatex !== undefined && acceptedLatex !== standardResult?.latex) ? acceptedLatex : standardResult?.latex; // Do not continue if no LaTeX found by either method. // This is necessary since both parameters can be independently undefined in some edge cases. if (latex === undefined && acceptedLatex === undefined) { return; } // Verify caret is inside LaTeX block for track changes edge cases. if (!standardResult && acceptedLatex !== undefined && !this.isCaretInsideLatexBlock(textNode, caretPosition)) { return; } const finalLatex = latex === undefined ? acceptedLatex : latex; this.storeLatexRangeWithFallback(textNode, caretPosition, finalLatex); return Latex.getMathMLFromLatex(finalLatex); } isCaretInsideLatexBlock(textNode, caretPosition = 0) { // If LaTeX is found, the caret is inside one. return this.extractAcceptedLatexFromDOM(textNode, caretPosition) !== undefined; } /** * Stores the LaTeX range for its replacement later. */ storeLatexRangeWithFallback(textNode, caretPosition, latex) { const parentTag = textNode.parentElement?.tagName?.toLowerCase(); if (!textNode.parentElement || parentTag === "textarea") { return; } const latexResult = Latex.getLatexFromTextNode(textNode, caretPosition); if (latexResult) { const range = document.createRange(); range.setStart(latexResult.startNode, latexResult.startPosition); range.setEnd(latexResult.endNode, latexResult.endPosition); this.core.editionProperties.latexRange = range; } else { this.core.editionProperties.latexRange = null; } this.core.editionProperties.extractedLatex = latex; } /** * Finds a container element containing a complete LaTeX block. * Necessary for track changes handling, to find the full LaTeX even with the suggestions. */ findLatexContainerElement(textNode) { const MAX_DEPTH = 10; // Prevent excessive loops. let element = textNode.parentElement; for (let i = 0; i < MAX_DEPTH && element; i++) { const text = element.textContent || ""; const openDelim = text.indexOf("$$"); if (openDelim !== -1 && text.includes("$$", openDelim + 2)) { return element; } element = element.parentElement; } return null; } /** * Extracts LaTeX from DOM, skipping track changes deletion markers. */ extractAcceptedLatexFromDOM(textNode, caretPositionInNode = 0) { const container = this.findLatexContainerElement(textNode); if (!container) { return; } const acceptedText = this.getAcceptedTextContent(container); // Calculate caret offset that will be used later to find the correct LaTeX block. // This includes all accepted text before textNode, plus the caret position within textNode. const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT); let node = walker.nextNode(); let caretOffset = 0; while (node && node !== textNode) { if (!node.parentElement?.classList?.contains("ck-suggestion-marker-deletion")) { caretOffset += node.textContent?.length || 0; } node = walker.nextNode(); } // Add the caret position within the text node, only if textNode is not deleted by Track Changes. if (node === textNode && !textNode.parentElement?.classList?.contains("ck-suggestion-marker-deletion")) { caretOffset += caretPositionInNode; } // Find the LaTeX block that contains the caret. let nextSearchIndex = 0; while (nextSearchIndex < acceptedText.length) { const openDelim = acceptedText.indexOf("$$", nextSearchIndex); if (openDelim === -1) { break; } const closeDelim = acceptedText.indexOf("$$", openDelim + 2); if (closeDelim === -1) { break; } if (caretOffset >= openDelim && caretOffset <= closeDelim + 2) { return acceptedText.substring(openDelim + 2, closeDelim); } nextSearchIndex = closeDelim + 2; } } /** * Recursively extracts text content, skipping track changes tags. */ getAcceptedTextContent(node) { if (node.nodeType === Node.TEXT_NODE) { return node.textContent || ""; } if (node.nodeType === Node.ELEMENT_NODE) { if (node.classList?.contains("ck-suggestion-marker-deletion")) { return ""; } return Array.from(node.childNodes).map((child) => this.getAcceptedTextContent(child)).join(""); } return ""; } /** Called when the modal window is closed. */ notifyWindowClosed() { this.editorObject.editing.view.focus(); } }