@zzish/math-rich-input 0.1.52 → 0.1.53

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/dist/index.js CHANGED
@@ -23034,6 +23034,10 @@ var MathRichInput = /*#__PURE__*/function (_React$Component) {
23034
23034
 
23035
23035
  // Stop editing if within rendered katex node, if somehow the cursor ends up there (it shouldn't)
23036
23036
  var params = _this2._getRangeParams();
23037
+ // No range means no caret to protect — a field the browser has not placed a selection in yet. It
23038
+ // reads as null on the first keystroke after a click on a toolbar button, and dereferencing it threw
23039
+ // on every Cmd, every Cmd+A and every Cmd+V the teacher pressed.
23040
+ if (params === null || params === undefined) return;
23037
23041
  var startNode = _this2._findNodeWithIndex(params.startNodeIndex);
23038
23042
  var endNode = _this2._findNodeWithIndex(params.endNodeIndex);
23039
23043
  if (!_this2.editableDivIsPlainText()) {
@@ -23086,7 +23090,12 @@ var MathRichInput = /*#__PURE__*/function (_React$Component) {
23086
23090
  // If the cursor is in a katex node, then move it to the next node (unless there is no
23087
23091
  // net node in which case move it to the previous node)
23088
23092
  if (e.key === "ArrowRight") {
23093
+ // `_getRangeParams` returns null whenever the document's selection is not inside this field —
23094
+ // which happens in ordinary use, not only in error: a toolbar button taking focus is enough. It
23095
+ // was dereferenced straight away, so the key handler THREW and everything after it in the same
23096
+ // press was skipped.
23089
23097
  var rangeParams = _this2._getRangeParams();
23098
+ if (!rangeParams) return;
23090
23099
  var index = rangeParams.startNodeIndex;
23091
23100
  if (index >= 0) {
23092
23101
  if (isKatexNode(_this2._findNodeWithIndex(index))) {
@@ -23115,7 +23124,9 @@ var MathRichInput = /*#__PURE__*/function (_React$Component) {
23115
23124
  }
23116
23125
  }
23117
23126
  if (e.key === "ArrowLeft") {
23127
+ // Same guard, same reason — see ArrowRight above.
23118
23128
  var _rangeParams = _this2._getRangeParams();
23129
+ if (!_rangeParams) return;
23119
23130
  var _index = _rangeParams.startNodeIndex;
23120
23131
 
23121
23132
  // If the cursor is in a katex node, then move it to the previous node (unless there is no
@@ -23331,8 +23342,29 @@ var MathRichInput = /*#__PURE__*/function (_React$Component) {
23331
23342
  var pasteHtml = clipboardData.getData("text/html");
23332
23343
  var pasteText = clipboardData.getData("text/plain");
23333
23344
 
23334
- // Get current cursor position
23335
- var rangeParams = _this2._getRangeParams();
23345
+ // Remove whatever was selected FIRST, then read the caret.
23346
+ //
23347
+ // The order used to be the other way round, and that is why pasting over a selection emptied the
23348
+ // field instead of replacing it. `rangeParams` is a NODE INDEX plus an offset, and
23349
+ // `deleteFromDocument()` removes the very nodes it counts: read before the delete, the index points
23350
+ // at something that no longer exists, so `_findNodeWithIndex` below finds nothing, the marked text
23351
+ // it builds carries no mark, and the pasted text is inserted nowhere. The value the component then
23352
+ // reports is correct while the editable div is left empty — which is exactly what a teacher sees.
23353
+ //
23354
+ // It only ever worked on an empty field because the canonical empty state is a single `<p>` holding
23355
+ // one small space: deleting that selection leaves the same node structure standing, so the stale
23356
+ // index still happens to resolve.
23357
+ // Read the caret BEFORE, so there is something to fall back on, and AGAIN after the delete.
23358
+ var paramsBeforeDelete = _this2._getRangeParams();
23359
+ var selection = window.getSelection();
23360
+ if (selection && selection.rangeCount > 0) {
23361
+ selection.deleteFromDocument();
23362
+ }
23363
+
23364
+ // The caret AFTER the delete is the one that counts: `rangeParams` is a node index, and the delete
23365
+ // removed the very nodes it counts. The pre-delete value is kept only as a fallback — bailing out
23366
+ // here having already emptied the selection is how a paste turns into a deletion.
23367
+ var rangeParams = _this2._getRangeParams() || paramsBeforeDelete;
23336
23368
  if (!rangeParams) {
23337
23369
  console.warn("Could not get range params for paste");
23338
23370
  return;
@@ -23340,12 +23372,6 @@ var MathRichInput = /*#__PURE__*/function (_React$Component) {
23340
23372
 
23341
23373
  // Store old range params for cursor positioning (like equation editor)
23342
23374
  _this2.setOldRangeParams(rangeParams);
23343
-
23344
- // Clear any existing selection
23345
- var selection = window.getSelection();
23346
- if (selection.rangeCount > 0) {
23347
- selection.deleteFromDocument();
23348
- }
23349
23375
  var finalText = "";
23350
23376
  var finalMimeType = _this2.props.mimeType || "text/html";
23351
23377
  var hasMathContent = false;
@@ -23448,38 +23474,41 @@ var MathRichInput = /*#__PURE__*/function (_React$Component) {
23448
23474
 
23449
23475
  // Insert the new content at the marked position
23450
23476
  var newRawText = removeMarks(insertCharacterBeforeMarks(markedText, finalText));
23451
-
23452
23477
  // Convert any remaining \[...\] LaTeX expressions to <math>...</math> format
23453
23478
  // This ensures consistency when paste adds <math> tags alongside existing \[...\] expressions
23454
23479
  if (_this2.enableMath()) {
23455
23480
  newRawText = newRawText.replace(/\\\[([^\]]*?)\\\]/g, "<math>$1</math>");
23456
23481
  }
23457
23482
 
23458
- // Calculate cursor position exactly like equation editor does
23483
+ /*
23484
+ * Where the caret lands after a paste: at the END of what was pasted.
23485
+ *
23486
+ * The node index walks to just past the last formula, and that part was always right. The OFFSET
23487
+ * within that node was not: it stopped at the boundary, so pasting "…multiply it by 3." left the
23488
+ * caret between the 3 and the full stop. Everything after the last `</math>` is plain text, and
23489
+ * plain text is the one thing whose rendered length is its written length — so the tail can
23490
+ * simply be stepped over. (Markup in the tail is not that, and keeps the old position rather than
23491
+ * a guessed one.)
23492
+ *
23493
+ * Deriving the position from scratch was tried twice and both attempts landed FURTHER away, each
23494
+ * needing an exact model of how the rendered document is measured and each getting a corner of it
23495
+ * wrong. Walking the structure that is already there needs no such model.
23496
+ */
23459
23497
  var newRangeParams = null;
23460
23498
  if (hasMathContent) {
23461
- // Use same approach as equation editor for math content
23462
23499
  var oldRangeParams = _this2.getOldRangeParams();
23463
23500
  var mathTagCount = (finalText.match(/<math>/gi) || []).length;
23464
- if (_this2.props.value === "") {
23465
- // Special case of previously empty text input (like equation editor)
23466
- newRangeParams = {
23467
- startNodeIndex: mathTagCount * 2,
23468
- startOffset: SMALL_SPACE_LENGTH,
23469
- endNodeIndex: mathTagCount * 2,
23470
- endOffset: SMALL_SPACE_LENGTH
23471
- };
23472
- } else {
23473
- // Normal case - position cursor after inserted math content (like equation editor)
23474
- newRangeParams = {
23475
- startNodeIndex: oldRangeParams.startNodeIndex + mathTagCount * 2,
23476
- startOffset: SMALL_SPACE_LENGTH,
23477
- endNodeIndex: oldRangeParams.startNodeIndex + mathTagCount * 2,
23478
- endOffset: SMALL_SPACE_LENGTH
23479
- };
23480
- }
23501
+ var startNodeIndex = _this2.props.value === "" ? mathTagCount * 2 : oldRangeParams.startNodeIndex + mathTagCount * 2;
23502
+ var lastMathEnd = finalText.toLowerCase().lastIndexOf("</math>");
23503
+ var tail = lastMathEnd === -1 ? "" : finalText.substring(lastMathEnd + "</math>".length);
23504
+ var startOffset = tail.indexOf("<") === -1 ? SMALL_SPACE_LENGTH + tail.length : SMALL_SPACE_LENGTH;
23505
+ newRangeParams = {
23506
+ startNodeIndex: startNodeIndex,
23507
+ startOffset: startOffset,
23508
+ endNodeIndex: startNodeIndex,
23509
+ endOffset: startOffset
23510
+ };
23481
23511
  } else {
23482
- // For text content, use global offset like before
23483
23512
  var newGlobalOffset = rangeParams.startGlobalOffset + finalText.length;
23484
23513
  newRangeParams = {
23485
23514
  startGlobalOffset: newGlobalOffset,
@@ -23502,6 +23531,23 @@ var MathRichInput = /*#__PURE__*/function (_React$Component) {
23502
23531
 
23503
23532
  // Apply the change using the component's standard flow
23504
23533
  _this2.applyChangesToComponent(cleanedRawText, actualMimeType, newRangeParams, _this2.props.useExpertMode, _this2.props.selectedTab);
23534
+
23535
+ // A paste cannot rely on being re-rendered. It removed the selected content from the DOM itself,
23536
+ // and if the host already holds the value being applied — the same words pasted back into the
23537
+ // field they came from — no prop changes, nothing re-renders, and the field is left empty while
23538
+ // the value is correct everywhere else. So: give a real render its chance, then put the content
23539
+ // back if none came.
23540
+ var rangeAfterPaste = newRangeParams;
23541
+ queueMicrotask(function () {
23542
+ try {
23543
+ if (_this2.reconcileEditableDomWithRender(true)) {
23544
+ _this2._setRangeParams(rangeAfterPaste);
23545
+ _this2.updateActiveButtons();
23546
+ }
23547
+ } catch (error) {
23548
+ console.error(error);
23549
+ }
23550
+ });
23505
23551
  }
23506
23552
  } catch (error) {
23507
23553
  console.error("Error in paste handler:", error);
@@ -23621,7 +23667,11 @@ var MathRichInput = /*#__PURE__*/function (_React$Component) {
23621
23667
 
23622
23668
  // Clear selection anchor on mouse click
23623
23669
  _this2.selectionAnchor = null;
23670
+
23671
+ // A click that lands with the selection outside this field — a toolbar button, another field —
23672
+ // gives no range at all. Dereferenced unguarded, this threw on every such click.
23624
23673
  var params = _this2._getRangeParams();
23674
+ if (!params) return;
23625
23675
  _this2.setOldRangeParams(params);
23626
23676
  var startNode = _this2._findNodeWithIndex(params.startNodeIndex);
23627
23677
  var endNode = _this2._findNodeWithIndex(params.endNodeIndex);
@@ -24955,6 +25005,70 @@ var MathRichInput = /*#__PURE__*/function (_React$Component) {
24955
25005
  }
24956
25006
  return true;
24957
25007
  }
25008
+
25009
+ /**
25010
+ * The browser's own reading of an HTML string.
25011
+ *
25012
+ * React renders `<p>&#8203;…</P>`; the DOM reports `<p>​…</p>` — same content, different bytes. So a
25013
+ * comparison against `innerHTML` has to be made in the DOM's spelling, or it never matches and the
25014
+ * reconciliation below would rewrite the field on every single update.
25015
+ */
25016
+ }, {
25017
+ key: "normaliseHtml",
25018
+ value: function normaliseHtml(html) {
25019
+ var probe = document.createElement("span");
25020
+ probe.innerHTML = html;
25021
+ return probe.innerHTML;
25022
+ }
25023
+
25024
+ /**
25025
+ * Put back what React believes it rendered, when the live DOM has drifted away from it.
25026
+ *
25027
+ * WHY THIS IS NEEDED AT ALL. `dangerouslySetInnerHTML` writes only when the html STRING changes: React
25028
+ * compares the new `__html` with the previous one and, finding them equal, leaves the DOM alone. That is
25029
+ * sound as long as React is the only thing that touches the DOM — and here it is not. `handlePaste`
25030
+ * removes the selected content itself, through `selection.deleteFromDocument()`, so the field empties
25031
+ * behind React's back while React's record of it still says "full".
25032
+ *
25033
+ * Paste the same words back into the field they came from and the two mistakes meet: the DOM is empty,
25034
+ * the html React computes is identical to the html it rendered last time, so it writes nothing — and
25035
+ * the field stays blank while `value`, the command, and the row in the database are all correct. The
25036
+ * teacher sees their text vanish on paste; nothing anywhere reports an error.
25037
+ *
25038
+ * It writes back the SAME string React itself passed to `dangerouslySetInnerHTML` on this render, so
25039
+ * nothing reaches the DOM here that React was not already putting there — the trust boundary is the
25040
+ * one that existed before, not a new one.
25041
+ *
25042
+ * Only reconciled after a PROGRAMMATIC edit (`afterComponentUpdateData` is set), or when the paste
25043
+ * asks directly (`force`). Ordinary typing goes down the native-input path, where the browser has
25044
+ * already put the character in the right place and the caret with it, and rewriting the DOM there
25045
+ * would move the caret to the front on every keystroke.
25046
+ *
25047
+ * `force` exists because a re-render is not guaranteed to happen AT ALL. When the host already holds
25048
+ * the value being applied — paste the same words back into the field they came from — the prop never
25049
+ * changes, so nothing re-renders and `componentDidUpdate` never runs. `lastRenderedEditableHtml` is
25050
+ * then exactly right: it is the html for that unchanged value, which is what the DOM should hold.
25051
+ *
25052
+ * Returns whether it wrote, so the caller knows whether the caret needs putting back.
25053
+ */
25054
+ }, {
25055
+ key: "reconcileEditableDomWithRender",
25056
+ value: function reconcileEditableDomWithRender() {
25057
+ var force = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
25058
+ if (!this.editableDiv) return false;
25059
+ if (!force && (this.afterComponentUpdateData === null || this.afterComponentUpdateData === undefined)) return false;
25060
+ var html = force ? rawTextToHtml(katex$1, this.props.value || "", this.props.mimeType) : this.lastRenderedEditableHtml;
25061
+ if (html === null || html === undefined) return false;
25062
+ if (this.editableDiv.innerHTML === this.normaliseHtml(html)) return false;
25063
+ debugMathRichInput("reconcile", {
25064
+ force: force,
25065
+ liveInnerHTML: this.editableDiv.innerHTML,
25066
+ html: html
25067
+ });
25068
+ this.editableDiv.innerHTML = html;
25069
+ this.lastRenderedEditableHtml = html;
25070
+ return true;
25071
+ }
24958
25072
  }, {
24959
25073
  key: "componentDidUpdate",
24960
25074
  value: function componentDidUpdate() {
@@ -24968,6 +25082,9 @@ var MathRichInput = /*#__PURE__*/function (_React$Component) {
24968
25082
  innerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
24969
25083
  selection: getSelectionSnapshot(this.editableDiv)
24970
25084
  });
25085
+ // Before the caret is restored, not after: the offsets below are counted through the text, so
25086
+ // restoring them against an empty field puts the caret at 0 and loses the position as well.
25087
+ this.reconcileEditableDomWithRender();
24971
25088
  if (this.afterComponentUpdateData !== null && this.afterComponentUpdateData !== undefined && this.afterComponentUpdateData.value === this.props.value) {
24972
25089
  debugMathRichInput("componentDidUpdate:restore-before", {
24973
25090
  rangeParams: this.afterComponentUpdateData.rangeParams,