@zzish/math-rich-input 0.1.52 → 0.1.54

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
@@ -21963,6 +21963,15 @@ function plainTextOffsetToGlobalOffset(editableDiv, plainTextOffset) {
21963
21963
  return Math.min(plainTextOffset, plainText.length);
21964
21964
  }
21965
21965
 
21966
+ /**
21967
+ * The character used to mark a position in text while content is being rewritten.
21968
+ *
21969
+ * Exported so a caller can tell whether a mark was actually placed. `insertCharacterBeforeMarks` warns
21970
+ * and returns its input UNCHANGED when there is no mark, which reads as success at the call site — the
21971
+ * value comes back looking valid with the inserted text silently missing.
21972
+ */
21973
+ var SELECTION_MARK = MARK$1;
21974
+
21966
21975
  var MIME_TYPE_PLAIN_TEXT = "text/plain";
21967
21976
  var MIME_TYPE_HTML = "text/html";
21968
21977
  var MIME_TYPE_TEX = "application/x-tex";
@@ -23034,6 +23043,10 @@ var MathRichInput = /*#__PURE__*/function (_React$Component) {
23034
23043
 
23035
23044
  // Stop editing if within rendered katex node, if somehow the cursor ends up there (it shouldn't)
23036
23045
  var params = _this2._getRangeParams();
23046
+ // No range means no caret to protect — a field the browser has not placed a selection in yet. It
23047
+ // reads as null on the first keystroke after a click on a toolbar button, and dereferencing it threw
23048
+ // on every Cmd, every Cmd+A and every Cmd+V the teacher pressed.
23049
+ if (params === null || params === undefined) return;
23037
23050
  var startNode = _this2._findNodeWithIndex(params.startNodeIndex);
23038
23051
  var endNode = _this2._findNodeWithIndex(params.endNodeIndex);
23039
23052
  if (!_this2.editableDivIsPlainText()) {
@@ -23086,7 +23099,12 @@ var MathRichInput = /*#__PURE__*/function (_React$Component) {
23086
23099
  // If the cursor is in a katex node, then move it to the next node (unless there is no
23087
23100
  // net node in which case move it to the previous node)
23088
23101
  if (e.key === "ArrowRight") {
23102
+ // `_getRangeParams` returns null whenever the document's selection is not inside this field —
23103
+ // which happens in ordinary use, not only in error: a toolbar button taking focus is enough. It
23104
+ // was dereferenced straight away, so the key handler THREW and everything after it in the same
23105
+ // press was skipped.
23089
23106
  var rangeParams = _this2._getRangeParams();
23107
+ if (!rangeParams) return;
23090
23108
  var index = rangeParams.startNodeIndex;
23091
23109
  if (index >= 0) {
23092
23110
  if (isKatexNode(_this2._findNodeWithIndex(index))) {
@@ -23115,7 +23133,9 @@ var MathRichInput = /*#__PURE__*/function (_React$Component) {
23115
23133
  }
23116
23134
  }
23117
23135
  if (e.key === "ArrowLeft") {
23136
+ // Same guard, same reason — see ArrowRight above.
23118
23137
  var _rangeParams = _this2._getRangeParams();
23138
+ if (!_rangeParams) return;
23119
23139
  var _index = _rangeParams.startNodeIndex;
23120
23140
 
23121
23141
  // If the cursor is in a katex node, then move it to the previous node (unless there is no
@@ -23331,7 +23351,17 @@ var MathRichInput = /*#__PURE__*/function (_React$Component) {
23331
23351
  var pasteHtml = clipboardData.getData("text/html");
23332
23352
  var pasteText = clipboardData.getData("text/plain");
23333
23353
 
23334
- // Get current cursor position
23354
+ /*
23355
+ * The caret is read BEFORE the selection is removed, and that order is deliberate.
23356
+ *
23357
+ * Reading it afterwards looks more correct — `deleteFromDocument()` removes the very nodes the
23358
+ * index counts — and it was tried, on the theory that a stale index was why pasting over a
23359
+ * selection emptied the field. It was not: the field emptied because React never wrote the
23360
+ * content back (see `reconcileEditableDomWithRender`), and the reordering was left in on a theory
23361
+ * that had already been disproved. In a host whose field holds unwrapped text rather than a `<p>`,
23362
+ * the emptied div yields a caret that `elementToMarkedRawText` cannot mark at all — no mark, so the
23363
+ * pasted text is inserted nowhere and the paste becomes a deletion.
23364
+ */
23335
23365
  var rangeParams = _this2._getRangeParams();
23336
23366
  if (!rangeParams) {
23337
23367
  console.warn("Could not get range params for paste");
@@ -23343,7 +23373,7 @@ var MathRichInput = /*#__PURE__*/function (_React$Component) {
23343
23373
 
23344
23374
  // Clear any existing selection
23345
23375
  var selection = window.getSelection();
23346
- if (selection.rangeCount > 0) {
23376
+ if (selection && selection.rangeCount > 0) {
23347
23377
  selection.deleteFromDocument();
23348
23378
  }
23349
23379
  var finalText = "";
@@ -23442,44 +23472,105 @@ var MathRichInput = /*#__PURE__*/function (_React$Component) {
23442
23472
 
23443
23473
  // Use the component's insert mechanism to handle paste properly
23444
23474
  if (finalText) {
23445
- // Get current node and create marked text at cursor position
23446
- var currentNode = _this2._findNodeWithIndex(rangeParams.startNodeIndex);
23447
- var markedText = elementToMarkedRawText(_this2.editableDiv, currentNode, rangeParams.startOffset, _this2.enableHtml());
23475
+ /*
23476
+ * WHERE TO PUT THE PASTED TEXT, found in the document rather than by index.
23477
+ *
23478
+ * `elementToMarkedRawText` marks ONE position — a node and an offset — and the selection has to
23479
+ * be gone from the document before that position means anything. It was, and then the position
23480
+ * was looked up by INDEX, which is where every version of this went wrong: an index read before
23481
+ * the delete counts nodes the delete then removes, and an index read after it does not resolve in
23482
+ * a field whose content is unwrapped text. Either way `_findNodeWithIndex` returns nothing, no
23483
+ * mark is placed, `insertCharacterBeforeMarks` warns "Start: -1" and returns the text UNCHANGED —
23484
+ * so the pasted words go nowhere, and what the teacher sees is their field emptied.
23485
+ *
23486
+ * The browser's own selection is the answer: after `deleteFromDocument()` it is left collapsed at
23487
+ * exactly the point where the pasted text belongs, in a node that exists by construction. The
23488
+ * index lookup stays as a fallback for the case where the selection has gone elsewhere.
23489
+ */
23490
+ var live = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
23491
+ var liveNode = live && _this2.editableDiv.contains(live.startContainer) ? live.startContainer : null;
23492
+ var currentNode = liveNode !== null ? liveNode : _this2._findNodeWithIndex(rangeParams.startNodeIndex);
23493
+ var currentOffset = liveNode !== null ? live.startOffset : rangeParams.startOffset;
23494
+ var markedText = elementToMarkedRawText(_this2.editableDiv, currentNode, currentOffset, _this2.enableHtml());
23495
+
23496
+ /*
23497
+ * A FIELD WITH NOTHING LEFT IN IT NEEDS NO MARK.
23498
+ *
23499
+ * Marking only works on a TEXT node, and an emptied field has none: the browser leaves the caret
23500
+ * in the element itself, so `elementToMarkedRawText` has nothing to mark and returns text with no
23501
+ * mark in it. `insertCharacterBeforeMarks` then warns and hands back its input UNCHANGED, which
23502
+ * reads as success — the value comes out valid with the pasted words missing, and the teacher
23503
+ * watches their field empty itself.
23504
+ *
23505
+ * But an empty field after the delete says something exact: the selection covered everything, so
23506
+ * the result of the paste IS the pasted text. Nothing needs to be worked out. This is the
23507
+ * select-all-and-paste that every report of this bug has been.
23508
+ */
23509
+ var remainingText = _this2.editableDiv.textContent.split(SMALL_SPACE).join("").trim();
23510
+ var fieldIsEmpty = remainingText === "" && _this2.editableDiv.querySelector(".katex") === null;
23511
+ var marked = markedText.indexOf(SELECTION_MARK) !== -1;
23512
+
23513
+ /*
23514
+ * NOTHING IS BETTER THAN DESTRUCTION.
23515
+ *
23516
+ * No mark and content still standing means the caret cannot be located inside text that must be
23517
+ * kept. Carrying on would write a value with the pasted text missing over a field whose selection
23518
+ * has already been removed from the document — a paste that performs a deletion. Stopping leaves
23519
+ * `props.value` untouched and `reconcileEditableDomWithRender` puts the document back from it:
23520
+ * the paste does nothing, which is a disappointment rather than a loss.
23521
+ */
23522
+ if (!marked && !fieldIsEmpty) {
23523
+ console.warn("[math-rich-input] paste found nowhere to insert; the field is left as it was", {
23524
+ rangeParams: rangeParams,
23525
+ hadLiveSelection: liveNode !== null
23526
+ });
23527
+ queueMicrotask(function () {
23528
+ try {
23529
+ _this2.reconcileEditableDomWithRender(true);
23530
+ } catch (error) {
23531
+ console.error(error);
23532
+ }
23533
+ });
23534
+ return;
23535
+ }
23448
23536
 
23449
23537
  // Insert the new content at the marked position
23450
- var newRawText = removeMarks(insertCharacterBeforeMarks(markedText, finalText));
23451
-
23538
+ var newRawText = marked ? removeMarks(insertCharacterBeforeMarks(markedText, finalText)) : finalText;
23452
23539
  // Convert any remaining \[...\] LaTeX expressions to <math>...</math> format
23453
23540
  // This ensures consistency when paste adds <math> tags alongside existing \[...\] expressions
23454
23541
  if (_this2.enableMath()) {
23455
23542
  newRawText = newRawText.replace(/\\\[([^\]]*?)\\\]/g, "<math>$1</math>");
23456
23543
  }
23457
23544
 
23458
- // Calculate cursor position exactly like equation editor does
23545
+ /*
23546
+ * Where the caret lands after a paste: at the END of what was pasted.
23547
+ *
23548
+ * The node index walks to just past the last formula, and that part was always right. The OFFSET
23549
+ * within that node was not: it stopped at the boundary, so pasting "…multiply it by 3." left the
23550
+ * caret between the 3 and the full stop. Everything after the last `</math>` is plain text, and
23551
+ * plain text is the one thing whose rendered length is its written length — so the tail can
23552
+ * simply be stepped over. (Markup in the tail is not that, and keeps the old position rather than
23553
+ * a guessed one.)
23554
+ *
23555
+ * Deriving the position from scratch was tried twice and both attempts landed FURTHER away, each
23556
+ * needing an exact model of how the rendered document is measured and each getting a corner of it
23557
+ * wrong. Walking the structure that is already there needs no such model.
23558
+ */
23459
23559
  var newRangeParams = null;
23460
23560
  if (hasMathContent) {
23461
- // Use same approach as equation editor for math content
23462
23561
  var oldRangeParams = _this2.getOldRangeParams();
23463
23562
  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
- }
23563
+ var startNodeIndex = _this2.props.value === "" ? mathTagCount * 2 : oldRangeParams.startNodeIndex + mathTagCount * 2;
23564
+ var lastMathEnd = finalText.toLowerCase().lastIndexOf("</math>");
23565
+ var tail = lastMathEnd === -1 ? "" : finalText.substring(lastMathEnd + "</math>".length);
23566
+ var startOffset = tail.indexOf("<") === -1 ? SMALL_SPACE_LENGTH + tail.length : SMALL_SPACE_LENGTH;
23567
+ newRangeParams = {
23568
+ startNodeIndex: startNodeIndex,
23569
+ startOffset: startOffset,
23570
+ endNodeIndex: startNodeIndex,
23571
+ endOffset: startOffset
23572
+ };
23481
23573
  } else {
23482
- // For text content, use global offset like before
23483
23574
  var newGlobalOffset = rangeParams.startGlobalOffset + finalText.length;
23484
23575
  newRangeParams = {
23485
23576
  startGlobalOffset: newGlobalOffset,
@@ -23502,6 +23593,23 @@ var MathRichInput = /*#__PURE__*/function (_React$Component) {
23502
23593
 
23503
23594
  // Apply the change using the component's standard flow
23504
23595
  _this2.applyChangesToComponent(cleanedRawText, actualMimeType, newRangeParams, _this2.props.useExpertMode, _this2.props.selectedTab);
23596
+
23597
+ // A paste cannot rely on being re-rendered. It removed the selected content from the DOM itself,
23598
+ // and if the host already holds the value being applied — the same words pasted back into the
23599
+ // field they came from — no prop changes, nothing re-renders, and the field is left empty while
23600
+ // the value is correct everywhere else. So: give a real render its chance, then put the content
23601
+ // back if none came.
23602
+ var rangeAfterPaste = newRangeParams;
23603
+ queueMicrotask(function () {
23604
+ try {
23605
+ if (_this2.reconcileEditableDomWithRender(true)) {
23606
+ _this2._setRangeParams(rangeAfterPaste);
23607
+ _this2.updateActiveButtons();
23608
+ }
23609
+ } catch (error) {
23610
+ console.error(error);
23611
+ }
23612
+ });
23505
23613
  }
23506
23614
  } catch (error) {
23507
23615
  console.error("Error in paste handler:", error);
@@ -23621,7 +23729,11 @@ var MathRichInput = /*#__PURE__*/function (_React$Component) {
23621
23729
 
23622
23730
  // Clear selection anchor on mouse click
23623
23731
  _this2.selectionAnchor = null;
23732
+
23733
+ // A click that lands with the selection outside this field — a toolbar button, another field —
23734
+ // gives no range at all. Dereferenced unguarded, this threw on every such click.
23624
23735
  var params = _this2._getRangeParams();
23736
+ if (!params) return;
23625
23737
  _this2.setOldRangeParams(params);
23626
23738
  var startNode = _this2._findNodeWithIndex(params.startNodeIndex);
23627
23739
  var endNode = _this2._findNodeWithIndex(params.endNodeIndex);
@@ -24955,6 +25067,70 @@ var MathRichInput = /*#__PURE__*/function (_React$Component) {
24955
25067
  }
24956
25068
  return true;
24957
25069
  }
25070
+
25071
+ /**
25072
+ * The browser's own reading of an HTML string.
25073
+ *
25074
+ * React renders `<p>&#8203;…</P>`; the DOM reports `<p>​…</p>` — same content, different bytes. So a
25075
+ * comparison against `innerHTML` has to be made in the DOM's spelling, or it never matches and the
25076
+ * reconciliation below would rewrite the field on every single update.
25077
+ */
25078
+ }, {
25079
+ key: "normaliseHtml",
25080
+ value: function normaliseHtml(html) {
25081
+ var probe = document.createElement("span");
25082
+ probe.innerHTML = html;
25083
+ return probe.innerHTML;
25084
+ }
25085
+
25086
+ /**
25087
+ * Put back what React believes it rendered, when the live DOM has drifted away from it.
25088
+ *
25089
+ * WHY THIS IS NEEDED AT ALL. `dangerouslySetInnerHTML` writes only when the html STRING changes: React
25090
+ * compares the new `__html` with the previous one and, finding them equal, leaves the DOM alone. That is
25091
+ * sound as long as React is the only thing that touches the DOM — and here it is not. `handlePaste`
25092
+ * removes the selected content itself, through `selection.deleteFromDocument()`, so the field empties
25093
+ * behind React's back while React's record of it still says "full".
25094
+ *
25095
+ * Paste the same words back into the field they came from and the two mistakes meet: the DOM is empty,
25096
+ * the html React computes is identical to the html it rendered last time, so it writes nothing — and
25097
+ * the field stays blank while `value`, the command, and the row in the database are all correct. The
25098
+ * teacher sees their text vanish on paste; nothing anywhere reports an error.
25099
+ *
25100
+ * It writes back the SAME string React itself passed to `dangerouslySetInnerHTML` on this render, so
25101
+ * nothing reaches the DOM here that React was not already putting there — the trust boundary is the
25102
+ * one that existed before, not a new one.
25103
+ *
25104
+ * Only reconciled after a PROGRAMMATIC edit (`afterComponentUpdateData` is set), or when the paste
25105
+ * asks directly (`force`). Ordinary typing goes down the native-input path, where the browser has
25106
+ * already put the character in the right place and the caret with it, and rewriting the DOM there
25107
+ * would move the caret to the front on every keystroke.
25108
+ *
25109
+ * `force` exists because a re-render is not guaranteed to happen AT ALL. When the host already holds
25110
+ * the value being applied — paste the same words back into the field they came from — the prop never
25111
+ * changes, so nothing re-renders and `componentDidUpdate` never runs. `lastRenderedEditableHtml` is
25112
+ * then exactly right: it is the html for that unchanged value, which is what the DOM should hold.
25113
+ *
25114
+ * Returns whether it wrote, so the caller knows whether the caret needs putting back.
25115
+ */
25116
+ }, {
25117
+ key: "reconcileEditableDomWithRender",
25118
+ value: function reconcileEditableDomWithRender() {
25119
+ var force = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
25120
+ if (!this.editableDiv) return false;
25121
+ if (!force && (this.afterComponentUpdateData === null || this.afterComponentUpdateData === undefined)) return false;
25122
+ var html = force ? rawTextToHtml(katex$1, this.props.value || "", this.props.mimeType) : this.lastRenderedEditableHtml;
25123
+ if (html === null || html === undefined) return false;
25124
+ if (this.editableDiv.innerHTML === this.normaliseHtml(html)) return false;
25125
+ debugMathRichInput("reconcile", {
25126
+ force: force,
25127
+ liveInnerHTML: this.editableDiv.innerHTML,
25128
+ html: html
25129
+ });
25130
+ this.editableDiv.innerHTML = html;
25131
+ this.lastRenderedEditableHtml = html;
25132
+ return true;
25133
+ }
24958
25134
  }, {
24959
25135
  key: "componentDidUpdate",
24960
25136
  value: function componentDidUpdate() {
@@ -24968,6 +25144,9 @@ var MathRichInput = /*#__PURE__*/function (_React$Component) {
24968
25144
  innerHTML: this.editableDiv ? this.editableDiv.innerHTML : null,
24969
25145
  selection: getSelectionSnapshot(this.editableDiv)
24970
25146
  });
25147
+ // Before the caret is restored, not after: the offsets below are counted through the text, so
25148
+ // restoring them against an empty field puts the caret at 0 and loses the position as well.
25149
+ this.reconcileEditableDomWithRender();
24971
25150
  if (this.afterComponentUpdateData !== null && this.afterComponentUpdateData !== undefined && this.afterComponentUpdateData.value === this.props.value) {
24972
25151
  debugMathRichInput("componentDidUpdate:restore-before", {
24973
25152
  rangeParams: this.afterComponentUpdateData.rangeParams,