@wiris/mathtype-ckeditor5 8.15.0 → 8.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wiris/mathtype-ckeditor5",
3
- "version": "8.15.0",
3
+ "version": "8.15.2",
4
4
  "description": "MathType Web for CKEditor5 editor",
5
5
  "keywords": [
6
6
  "chem",
@@ -48,7 +48,7 @@
48
48
  "prepare": "npm run build:dist"
49
49
  },
50
50
  "dependencies": {
51
- "@wiris/mathtype-html-integration-devkit": "1.17.13"
51
+ "@wiris/mathtype-html-integration-devkit": "1.17.15"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@ckeditor/ckeditor5-dev-build-tools": "^42.1.0"
@@ -146,58 +146,84 @@ export default class CKEditor5Integration extends IntegrationModel {
146
146
  * @returns {module:engine/model/element~Element} The model element corresponding to the inserted image
147
147
  */
148
148
  insertMathml(mathml) {
149
- // This returns the value returned by the callback function (writer => {...})
150
149
  return this.editorObject.model.change((writer) => {
151
- const core = this.getCore();
150
+ const { isNewElement, temporalImage } = this.getCore().editionProperties;
152
151
  const selection = this.editorObject.model.document.selection;
152
+ const attributes = Object.fromEntries(selection.getAttributes());
153
+ const modelElementNew = writer.createElement("mathml", { formula: mathml, ...attributes });
153
154
 
154
- const modelElementNew = writer.createElement("mathml", {
155
- formula: mathml,
156
- ...Object.fromEntries(selection.getAttributes()), // To keep the format, such as style and font
157
- });
155
+ if (isNewElement) {
156
+ return this.insertNewFormula(writer, mathml, modelElementNew);
157
+ }
158
158
 
159
- // Obtain the DOM <span><img ... /></span> object corresponding to the formula
160
- if (core.editionProperties.isNewElement) {
161
- // Don't bother inserting anything at all if the MathML is empty.
162
- if (!mathml) return;
159
+ return this.replaceExistingFormula(mathml, modelElementNew, temporalImage);
160
+ });
161
+ }
163
162
 
164
- const viewSelection =
165
- this.core.editionProperties.selection || this.editorObject.editing.view.document.selection;
166
- const modelPosition = this.editorObject.editing.mapper.toModelPosition(viewSelection.getLastPosition());
163
+ /**
164
+ * Inserts a new formula at the current selection position.
165
+ */
166
+ insertNewFormula(writer, mathml, modelElement) {
167
+ if (!mathml) {
168
+ return;
169
+ }
167
170
 
168
- this.editorObject.model.insertObject(modelElementNew, modelPosition);
171
+ const viewSelection =
172
+ this.core.editionProperties.selection || this.editorObject.editing.view.document.selection;
173
+ const modelPosition = this.editorObject.editing.mapper.toModelPosition(viewSelection.getLastPosition());
169
174
 
170
- // Remove selection
171
- if (!viewSelection.isCollapsed) {
172
- for (const range of viewSelection.getRanges()) {
173
- const modelRange = this.editorObject.editing.mapper.toModelRange(range);
174
- const modelSelection = this.editorObject.model.createSelection(modelRange);
175
+ this.editorObject.model.insertObject(modelElement, modelPosition);
176
+ this.deleteViewSelection(viewSelection);
175
177
 
176
- this.editorObject.model.deleteContent(modelSelection);
177
- }
178
- }
178
+ // Set carret after the formula.
179
+ const position = this.editorObject.model.createPositionAfter(modelElement);
180
+ writer.setSelection(position);
179
181
 
180
- // Set carret after the formula
181
- const position = this.editorObject.model.createPositionAfter(modelElementNew);
182
- writer.setSelection(position);
183
- } else {
184
- const img = core.editionProperties.temporalImage;
185
- const viewElement = this.editorObject.editing.view.domConverter.domToView(img).parent;
186
- const modelElementOld = this.editorObject.editing.mapper.toModelElement(viewElement);
182
+ return modelElement;
183
+ }
187
184
 
188
- // Insert the new <mathml> and remove the old one
189
- const position = this.editorObject.model.createPositionBefore(modelElementOld);
185
+ deleteViewSelection(viewSelection) {
186
+ if (viewSelection.isCollapsed) {
187
+ return;
188
+ }
190
189
 
191
- // If the given MathML is empty, don't insert a new formula.
192
- if (mathml) {
193
- this.editorObject.model.insertObject(modelElementNew, position);
194
- }
195
- this.editorObject.model.deleteContent(this.editorObject.model.createSelection(modelElementOld,'on'));
190
+ for (const range of viewSelection.getRanges()) {
191
+ const modelRange = this.editorObject.editing.mapper.toModelRange(range);
192
+ const modelSelection = this.editorObject.model.createSelection(modelRange);
193
+
194
+ this.editorObject.model.deleteContent(modelSelection);
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Replaces an existing formula with updated MathML.
200
+ */
201
+ replaceExistingFormula(mathml, modelElement, temporalImage) {
202
+ const viewNode = this.editorObject.editing.view.domConverter.domToView(temporalImage);
203
+
204
+ // Check if image exists in view to do standard formula editing
205
+ if (viewNode?.parent) {
206
+ const modelElementOld = this.editorObject.editing.mapper.toModelElement(viewNode.parent);
207
+
208
+ // Insert the new <mathml> and remove the old one
209
+ const position = this.editorObject.model.createPositionBefore(modelElementOld);
210
+
211
+ if (mathml) {
212
+ this.editorObject.model.insertObject(modelElement, position);
196
213
  }
197
214
 
198
- // eslint-disable-next-line consistent-return
199
- return modelElementNew;
200
- });
215
+ this.editorObject.model.deleteContent(this.editorObject.model.createSelection(modelElementOld, "on"));
216
+ return modelElement;
217
+ }
218
+
219
+ // Otherwise it's LaTeX editing, so we insert at current selection
220
+ if (!mathml) {
221
+ return;
222
+ }
223
+
224
+ this.editorObject.model.insertContent(modelElement);
225
+
226
+ return modelElement;
201
227
  }
202
228
 
203
229
  /**
@@ -233,113 +259,442 @@ export default class CKEditor5Integration extends IntegrationModel {
233
259
  }
234
260
 
235
261
  /** @inheritdoc */
236
- insertFormula(focusElement, windowTarget, mathml, wirisProperties) {
262
+ insertFormula(_focusElement, windowTarget, mathml, _wirisProperties) {
237
263
  // eslint-disable-line no-unused-vars
238
264
  const returnObject = {};
239
-
240
265
  let mathmlOrigin;
266
+
241
267
  if (!mathml) {
242
268
  this.insertMathml("");
243
269
  } else if (this.core.editMode === "latex") {
244
- returnObject.latex = Latex.getLatexFromMathML(mathml);
245
- returnObject.node = windowTarget.document.createTextNode(`$$${returnObject.latex}$$`);
270
+ this.handleLatexInsertion(returnObject, windowTarget, mathml);
271
+ } else {
272
+ mathmlOrigin = this.handleMathmlInsertion(returnObject, windowTarget, mathml);
273
+ }
246
274
 
247
- this.editorObject.model.change((writer) => {
248
- const { latexRange } = this.core.editionProperties;
275
+ const payload = {
276
+ mathml: mathml ? MathML.safeXmlDecode(mathml) : undefined,
277
+ elapsed_time: Date.now() - this.core.editionProperties.editionStartTime,
278
+ toolbar: this.core.modalDialog.contentManager.toolbar,
279
+ size: mathml?.length,
280
+ };
249
281
 
250
- const startNode = this.findText(latexRange.startContainer);
251
- const endNode = this.findText(latexRange.endContainer);
282
+ if (mathmlOrigin) {
283
+ payload.mathml_origin = MathML.safeXmlDecode(mathmlOrigin);
284
+ }
252
285
 
253
- let startPosition = writer.createPositionAt(startNode.parent, startNode.startOffset + latexRange.startOffset);
254
- let endPosition = writer.createPositionAt(endNode.parent, endNode.startOffset + latexRange.endOffset);
286
+ try {
287
+ Telemeter.telemeter.track("INSERTED_FORMULA", {
288
+ ...payload,
289
+ });
290
+ } catch (error) {
291
+ console.error("Error tracking INSERTED_FORMULA", error);
292
+ }
255
293
 
256
- let range = writer.createRange(startPosition, endPosition);
294
+ this.core.editionProperties.temporalImage = null;
257
295
 
258
- // When Latex is next to image/formula.
259
- if (latexRange.startContainer.nodeType === 3 && latexRange.startContainer.previousSibling?.nodeType === 1) {
260
- // Get the position of the latex to be replaced.
261
- const latexEdited = `$$${Latex.getLatexFromMathML(
262
- MathML.safeXmlDecode(this.core.editionProperties.temporalImage.dataset.mathml),
263
- )}$$`;
264
- let data = latexRange.startContainer.data;
296
+ return returnObject;
297
+ }
265
298
 
266
- // Remove invisible characters.
267
- data = data.replaceAll(String.fromCharCode(8288), "");
299
+ handleLatexInsertion(returnObject, windowTarget, mathml) {
300
+ returnObject.latex = Latex.getLatexFromMathML(mathml);
301
+ returnObject.node = windowTarget.document.createTextNode(`$$${returnObject.latex}$$`);
268
302
 
269
- // Get to the start of the latex we are editing.
270
- const offset = data.indexOf(latexEdited);
271
- const dataOffset = data.substring(offset);
272
- const second$ = dataOffset.substring(2).indexOf("$$") + 4;
273
- const substring = dataOffset.substr(0, second$);
274
- data = data.replace(substring, "");
303
+ const { latexRange } = this.core.editionProperties;
275
304
 
276
- if (!data) {
277
- startPosition = writer.createPositionBefore(startNode);
278
- range = startNode;
279
- } else {
280
- startPosition = startPosition = writer.createPositionAt(startNode.parent, startNode.startOffset + offset);
281
- endPosition = writer.createPositionAt(endNode.parent, endNode.startOffset + second$ + offset);
282
- range = writer.createRange(startPosition, endPosition);
283
- }
284
- }
305
+ // When latexRange exists (meaning the whole LaTeX was selected or the editor was opened),
306
+ // find the node contaning the LaTeX and replace it fully.
307
+ if (latexRange) {
308
+ const startNode = this.findText(latexRange.startContainer);
309
+ const endNode = this.findText(latexRange.endContainer);
285
310
 
286
- const modelSelection = this.editorObject.model.createSelection(range);
311
+ // If nodes found, use standard replacement.
312
+ if (startNode && endNode) {
313
+ this.replaceLatexWithNodes(startNode, endNode, latexRange, returnObject.latex);
314
+ return;
315
+ }
316
+ }
287
317
 
288
- this.editorObject.model.deleteContent(modelSelection);
289
- writer.insertText(`$$${returnObject.latex}$$`, startNode.getAttributes(), startPosition);
290
- });
318
+ this.replaceLatexUsingModelSearch(returnObject.latex);
319
+ }
320
+
321
+ handleMathmlInsertion(returnObject, windowTarget, mathml) {
322
+ const mathmlOrigin = this.core.editionProperties.temporalImage?.dataset.mathml;
323
+
324
+ try {
325
+ const modelElement = this.insertMathml(mathml);
326
+ const viewElement = this.editorObject.editing.mapper.toViewElement(modelElement);
327
+
328
+ returnObject.node = this.editorObject.editing.view.domConverter.viewToDom(viewElement, windowTarget.document);
329
+ } catch (error) {
330
+ if (error.toString().includes("Cannot read property 'parent' of undefined")) {
331
+ this.core.modalDialog.cancelAction();
332
+ }
333
+ }
334
+
335
+ return mathmlOrigin;
336
+ }
337
+
338
+ /**
339
+ * Gets selection attributes excluding track changes tags.
340
+ */
341
+ getCleanSelectionAttributes() {
342
+ const attributes = {};
343
+
344
+ for (const [key, value] of this.editorObject.model.document.selection.getAttributes()) {
345
+ if (!key.startsWith("suggestion:") && !key.startsWith("comment:")) {
346
+ attributes[key] = value;
347
+ }
348
+ }
349
+
350
+ return attributes;
351
+ }
352
+
353
+ /**
354
+ * Searches for the original LaTeX in the model and replaces it.
355
+ * Fallback when findText() cannot locate DOM nodes (like when there are track changes modifications).
356
+ */
357
+ replaceLatexUsingModelSearch(newLatex) {
358
+ const foundRange = this.findLatexBlockNearSelection();
359
+
360
+ if (foundRange) {
361
+ this.editorObject.model.change((writer) => writer.setSelection(foundRange));
362
+ this.replaceRangeWithLatex(newLatex);
291
363
  } else {
292
- mathmlOrigin = this.core.editionProperties.temporalImage?.dataset.mathml;
293
- try {
294
- returnObject.node = this.editorObject.editing.view.domConverter.viewToDom(
295
- this.editorObject.editing.mapper.toViewElement(this.insertMathml(mathml)),
296
- windowTarget.document,
297
- );
298
- } catch (e) {
299
- const x = e.toString();
300
- if (x.includes("CKEditorError: Cannot read property 'parent' of undefined")) {
301
- this.core.modalDialog.cancelAction();
364
+ // Insert at current position as a last resort.
365
+ this.editorObject.model.change((writer) => {
366
+ const newLatexText = writer.createText(`$$${newLatex}$$`, this.getCleanSelectionAttributes());
367
+ this.editorObject.model.insertContent(newLatexText);
368
+ });
369
+ }
370
+
371
+ this.core.editionProperties.extractedLatex = null;
372
+ }
373
+
374
+ /**
375
+ * Checks if a text proxy has a track changes deletion marker.
376
+ */
377
+ isDeletedText(text) {
378
+ for (const [key, value] of text.getAttributes()) {
379
+ if (key.startsWith("suggestion:") && value === "deletion") {
380
+ return true;
381
+ }
382
+ }
383
+
384
+ return false;
385
+ }
386
+
387
+ /**
388
+ * Finds a LaTeX block ($$...$$) near the current selection.
389
+ * Handles track changes by considering the "accepted" version of text.
390
+ */
391
+ findLatexBlockNearSelection() {
392
+ const position = this.editorObject.model.document.selection.getFirstPosition();
393
+
394
+ if (!position?.parent) {
395
+ return;
396
+ }
397
+
398
+ // Build LaTeX with track changes accepted suggestions, if any.
399
+ const { textParts, acceptedText } = this.collectTextParts(position.parent);
400
+
401
+ if (!acceptedText.includes("$$")) {
402
+ return;
403
+ }
404
+
405
+ // To handle multiple LaTeX on same line.
406
+ const targetLatex = this.core.editionProperties.extractedLatex;
407
+ const fullLatex = `$$${targetLatex}$$`;
408
+ const startIndex = acceptedText.indexOf(fullLatex);
409
+
410
+ if (startIndex === -1) {
411
+ return;
412
+ }
413
+
414
+ const latexBoundaries = { start: startIndex, end: startIndex + fullLatex.length };
415
+
416
+ return this.convertAcceptedOffsetsToModelRange(textParts, latexBoundaries);
417
+ }
418
+
419
+ /**
420
+ * Collects all text fragments from a paragraph, tracking both model and accepted text positions.
421
+ * This is necessary to handle track changes where some LaTeX may have suggestions.
422
+ */
423
+ collectTextParts(paragraph) {
424
+ const textParts = [];
425
+ let acceptedTextOffset = 0;
426
+ let acceptedText = "";
427
+
428
+ for (const item of this.editorObject.model.createRangeIn(paragraph).getItems()) {
429
+ if (item.is("$textProxy")) {
430
+ const isDeleted = this.isDeletedText(item);
431
+
432
+ textParts.push({
433
+ text: item.data,
434
+ startOffset: item.startOffset,
435
+ endOffset: item.startOffset + item.data.length,
436
+ parent: item.textNode.parent,
437
+ acceptedStart: isDeleted ? null : acceptedTextOffset,
438
+ acceptedEnd: isDeleted ? null : acceptedTextOffset + item.data.length,
439
+ isDeleted
440
+ });
441
+
442
+ if (!isDeleted) {
443
+ acceptedText += item.data;
444
+ acceptedTextOffset += item.data.length;
302
445
  }
303
446
  }
304
447
  }
448
+ return { textParts, acceptedText };
449
+ }
305
450
 
306
- // Build the telemeter payload separated to delete null/undefined entries.
307
- const payload = {
308
- mathml_origin: mathmlOrigin ? MathML.safeXmlDecode(mathmlOrigin) : mathmlOrigin,
309
- mathml: mathml ? MathML.safeXmlDecode(mathml) : mathml,
310
- elapsed_time: Date.now() - this.core.editionProperties.editionStartTime,
311
- editor_origin: null, // TODO read formula to find out whether it comes from Oxygen Desktop
312
- toolbar: this.core.modalDialog.contentManager.toolbar,
313
- size: mathml?.length,
314
- };
451
+ /**
452
+ * Converts LaTeX with track changes accepted suggestions to a CKEditor model Range.
453
+ */
454
+ convertAcceptedOffsetsToModelRange(textParts, latexBoundaries) {
455
+ let startPartIndex = -1, endPartIndex = -1;
456
+ let startOffsetInPart = 0, endOffsetInPart = 0;
457
+
458
+ // Find which text parts contain the LaTeX block boundaries
459
+ for (let i = 0; i < textParts.length; i++) {
460
+ const part = textParts[i];
461
+ if (part.isDeleted) continue;
462
+
463
+ if (startPartIndex === -1 && latexBoundaries.start >= part.acceptedStart && latexBoundaries.start <= part.acceptedEnd) {
464
+ startPartIndex = i;
465
+ startOffsetInPart = latexBoundaries.start - part.acceptedStart;
466
+ }
467
+
468
+ if (latexBoundaries.end >= part.acceptedStart && latexBoundaries.end <= part.acceptedEnd) {
469
+ endPartIndex = i;
470
+ endOffsetInPart = latexBoundaries.end - part.acceptedStart;
471
+ }
472
+ }
473
+
474
+ if (startPartIndex === -1 || endPartIndex === -1) {
475
+ return;
476
+ }
477
+
478
+ // Extend range to include any consecutive deleted parts after the block.
479
+ let finalEndIndex = endPartIndex;
480
+ let finalEndOffset = endOffsetInPart;
481
+
482
+ for (let i = endPartIndex + 1; i < textParts.length && textParts[i].isDeleted; i++) {
483
+ finalEndIndex = i;
484
+ finalEndOffset = textParts[i].text.length;
485
+ }
315
486
 
316
- // Remove desired null keys.
317
- Object.keys(payload).forEach((key) => {
318
- if (key === "mathml_origin" || key === "editor_origin") !payload[key] ? delete payload[key] : {};
487
+ const startPart = textParts[startPartIndex];
488
+ const endPart = textParts[finalEndIndex];
489
+
490
+ return this.editorObject.model.createRange(
491
+ this.editorObject.model.createPositionAt(startPart.parent, startPart.startOffset + startOffsetInPart),
492
+ this.editorObject.model.createPositionAt(endPart.parent, endPart.startOffset + finalEndOffset)
493
+ );
494
+ }
495
+
496
+ replaceRangeWithLatex(newLatex) {
497
+ this.editorObject.model.change((writer) => {
498
+ this.editorObject.model.deleteContent(this.editorObject.model.document.selection);
499
+
500
+ const newLatexText = writer.createText(`$$${newLatex}$$`, this.getCleanSelectionAttributes());
501
+ this.editorObject.model.insertContent(newLatexText);
319
502
  });
503
+ }
320
504
 
321
- // Call Telemetry service to track the event.
322
- try {
323
- Telemeter.telemeter.track("INSERTED_FORMULA", {
324
- ...payload,
325
- });
326
- } catch (error) {
327
- console.error("Error tracking INSERTED_FORMULA", error);
505
+ /**
506
+ * Replaces the whole LaTeX in the CKEditor5 model.
507
+ */
508
+ replaceLatexWithNodes(startNode, endNode, latexRange, newLatex) {
509
+ this.editorObject.model.change((writer) => {
510
+ const startOffset = startNode.startOffset + latexRange.startOffset;
511
+ const endOffset = endNode.startOffset + latexRange.endOffset;
512
+
513
+ let startPosition = writer.createPositionAt(startNode.parent, startOffset);
514
+ let endPosition = writer.createPositionAt(endNode.parent, endOffset);
515
+
516
+ // Adjust positions when LaTeX is adjacent to a formula.
517
+ const startContainer = latexRange.startContainer;
518
+ if (startContainer.nodeType === Node.TEXT_NODE && startContainer.previousSibling?.nodeType === Node.ELEMENT_NODE) {
519
+ const originalLatex = `$$${Latex.getLatexFromMathML(
520
+ MathML.safeXmlDecode(this.core.editionProperties.temporalImage.dataset.mathml),
521
+ )}$$`;
522
+ const textData = startContainer.data.replaceAll(String.fromCodePoint(8288), "");
523
+ const latexOffset = textData.indexOf(originalLatex);
524
+
525
+ if (latexOffset !== -1) {
526
+ const closingDelimiterOffset = textData.substring(latexOffset + 2).indexOf("$$") + 4;
527
+ startPosition = writer.createPositionAt(startNode.parent, startNode.startOffset + latexOffset);
528
+ endPosition = writer.createPositionAt(endNode.parent, endNode.startOffset + closingDelimiterOffset + latexOffset);
529
+ }
530
+ }
531
+
532
+ writer.setSelection(writer.createRange(startPosition, endPosition));
533
+ });
534
+
535
+ this.replaceRangeWithLatex(newLatex);
536
+ }
537
+
538
+ /**
539
+ * Inherited method from IntegrationModel.
540
+ * Gets the MathML from a text node containing LaTeX.
541
+ * Handles track changes by simulating "accept all changes" before conversion.
542
+ */
543
+ getMathmlFromTextNode(textNode, caretPosition) {
544
+ const standardResult = Latex.getLatexFromTextNode(textNode, caretPosition);
545
+ const acceptedLatex = this.extractAcceptedLatexFromDOM(textNode, caretPosition);
546
+
547
+ // Prioritize accepted LaTeX if it differs from standard extraction (for track changes compatibility).
548
+ // Important node: use explicit undefined check to allow empty LaTeX strings, otherwise it would not detect $$$$ as valid LaTeX.
549
+ const latex = (acceptedLatex !== undefined && acceptedLatex !== standardResult?.latex)
550
+ ? acceptedLatex
551
+ : standardResult?.latex;
552
+
553
+ // Do not continue if no LaTeX found by either method.
554
+ // This is necessary since both parameters can be independently undefined in some edge cases.
555
+ if (latex === undefined && acceptedLatex === undefined) {
556
+ return;
328
557
  }
329
558
 
330
- /* Due to PLUGINS-1329, we add the onChange event to the CK4 insertFormula.
331
- We probably should add it here as well, but we should look further into how */
332
- // this.editorObject.fire('change');
559
+ // Verify caret is inside LaTeX block for track changes edge cases.
560
+ if (!standardResult && acceptedLatex !== undefined && !this.isCaretInsideLatexBlock(textNode, caretPosition)) {
561
+ return;
562
+ }
333
563
 
334
- // Remove temporal image of inserted formula
335
- this.core.editionProperties.temporalImage = null;
564
+ const finalLatex = latex === undefined ? acceptedLatex : latex;
336
565
 
337
- return returnObject;
566
+ this.storeLatexRangeWithFallback(textNode, caretPosition, finalLatex);
567
+
568
+ return Latex.getMathMLFromLatex(finalLatex);
569
+ }
570
+
571
+ isCaretInsideLatexBlock(textNode, caretPosition = 0) {
572
+ // If LaTeX is found, the caret is inside one.
573
+ return this.extractAcceptedLatexFromDOM(textNode, caretPosition) !== undefined;
338
574
  }
339
575
 
340
576
  /**
341
- * Function called when the content submits an action.
577
+ * Stores the LaTeX range for its replacement later.
342
578
  */
579
+ storeLatexRangeWithFallback(textNode, caretPosition, latex) {
580
+ const parentTag = textNode.parentElement?.tagName?.toLowerCase();
581
+
582
+ if (!textNode.parentElement || parentTag === "textarea") {
583
+ return;
584
+ }
585
+
586
+ const latexResult = Latex.getLatexFromTextNode(textNode, caretPosition);
587
+
588
+ if (latexResult) {
589
+ const range = document.createRange();
590
+
591
+ range.setStart(latexResult.startNode, latexResult.startPosition);
592
+ range.setEnd(latexResult.endNode, latexResult.endPosition);
593
+ this.core.editionProperties.latexRange = range;
594
+ } else {
595
+ this.core.editionProperties.latexRange = null;
596
+ }
597
+
598
+ this.core.editionProperties.extractedLatex = latex;
599
+ }
600
+
601
+ /**
602
+ * Finds a container element containing a complete LaTeX block.
603
+ * Necessary for track changes handling, to find the full LaTeX even with the suggestions.
604
+ */
605
+ findLatexContainerElement(textNode) {
606
+ const MAX_DEPTH = 10; // Prevent excessive loops.
607
+ let element = textNode.parentElement;
608
+
609
+ for (let i = 0; i < MAX_DEPTH && element; i++) {
610
+ const text = element.textContent || "";
611
+ const openDelim = text.indexOf("$$");
612
+
613
+ if (openDelim !== -1 && text.includes("$$", openDelim + 2)) {
614
+ return element;
615
+ }
616
+
617
+ element = element.parentElement;
618
+ }
619
+
620
+ return null;
621
+ }
622
+
623
+ /**
624
+ * Extracts LaTeX from DOM, skipping track changes deletion markers.
625
+ */
626
+ extractAcceptedLatexFromDOM(textNode, caretPositionInNode = 0) {
627
+ const container = this.findLatexContainerElement(textNode);
628
+
629
+ if (!container) {
630
+ return;
631
+ }
632
+
633
+ const acceptedText = this.getAcceptedTextContent(container);
634
+
635
+ // Calculate caret offset that will be used later to find the correct LaTeX block.
636
+ // This includes all accepted text before textNode, plus the caret position within textNode.
637
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
638
+ let node = walker.nextNode();
639
+ let caretOffset = 0;
640
+
641
+ while (node && node !== textNode) {
642
+ if (!node.parentElement?.classList?.contains("ck-suggestion-marker-deletion")) {
643
+ caretOffset += node.textContent?.length || 0;
644
+ }
645
+
646
+ node = walker.nextNode();
647
+ }
648
+
649
+ // Add the caret position within the text node, only if textNode is not deleted by Track Changes.
650
+ if (node === textNode && !textNode.parentElement?.classList?.contains("ck-suggestion-marker-deletion")) {
651
+ caretOffset += caretPositionInNode;
652
+ }
653
+
654
+ // Find the LaTeX block that contains the caret.
655
+ let nextSearchIndex = 0;
656
+
657
+ while (nextSearchIndex < acceptedText.length) {
658
+ const openDelim = acceptedText.indexOf("$$", nextSearchIndex);
659
+
660
+ if (openDelim === -1) {
661
+ break;
662
+ }
663
+
664
+ const closeDelim = acceptedText.indexOf("$$", openDelim + 2);
665
+
666
+ if (closeDelim === -1) {
667
+ break;
668
+ }
669
+
670
+ if (caretOffset >= openDelim && caretOffset <= closeDelim + 2) {
671
+ return acceptedText.substring(openDelim + 2, closeDelim);
672
+ }
673
+
674
+ nextSearchIndex = closeDelim + 2;
675
+ }
676
+ }
677
+
678
+ /**
679
+ * Recursively extracts text content, skipping track changes tags.
680
+ */
681
+ getAcceptedTextContent(node) {
682
+ if (node.nodeType === Node.TEXT_NODE) {
683
+ return node.textContent || "";
684
+ }
685
+
686
+ if (node.nodeType === Node.ELEMENT_NODE) {
687
+ if (node.classList?.contains("ck-suggestion-marker-deletion")) {
688
+ return "";
689
+ }
690
+
691
+ return Array.from(node.childNodes).map((child) => this.getAcceptedTextContent(child)).join("");
692
+ }
693
+
694
+ return "";
695
+ }
696
+
697
+ /** Called when the modal window is closed. */
343
698
  notifyWindowClosed() {
344
699
  this.editorObject.editing.view.focus();
345
700
  }